diff --git a/http/http-client-common/src/main/java/io/koraframework/http/client/common/request/mapper/HttpClientRequestMapperModule.java b/http/http-client-common/src/main/java/io/koraframework/http/client/common/request/mapper/HttpClientRequestMapperModule.java index 17cc0e68e..aed08c454 100644 --- a/http/http-client-common/src/main/java/io/koraframework/http/client/common/request/mapper/HttpClientRequestMapperModule.java +++ b/http/http-client-common/src/main/java/io/koraframework/http/client/common/request/mapper/HttpClientRequestMapperModule.java @@ -4,6 +4,7 @@ import io.koraframework.common.annotation.Tag; import io.koraframework.http.client.common.request.HttpClientRequestMapper; import io.koraframework.http.common.body.HttpBody; +import io.koraframework.http.common.body.HttpBodyOutput; import io.koraframework.json.common.JsonWriter; import io.koraframework.json.common.annotation.Json; @@ -16,6 +17,11 @@ default HttpClientRequestMapper httpClientRequestByteArrayMapper() { return (body) -> HttpBody.octetStream(body); } + @DefaultComponent + default HttpClientRequestMapper httpClientRequestBodyOutputMapper() { + return body -> body; + } + @DefaultComponent default HttpClientRequestMapper httpClientRequestByteBufferMapper() { return (body) -> HttpBody.octetStream(body); @@ -36,7 +42,7 @@ default FormMultipartClientRequestMapper httpClientRequestFormMultipartMapper() return new FormMultipartClientRequestMapper(); } - @Tag(Json.class) + @Json @DefaultComponent default JsonHttpClientRequestMapper httpClientRequestJsonMapper(JsonWriter writer) { return new JsonHttpClientRequestMapper<>(writer); diff --git a/http/http-client-common/src/main/java/io/koraframework/http/client/common/response/HttpClientResponseMapperModule.java b/http/http-client-common/src/main/java/io/koraframework/http/client/common/response/HttpClientResponseMapperModule.java index bcfd30b1f..287f977d0 100644 --- a/http/http-client-common/src/main/java/io/koraframework/http/client/common/response/HttpClientResponseMapperModule.java +++ b/http/http-client-common/src/main/java/io/koraframework/http/client/common/response/HttpClientResponseMapperModule.java @@ -4,6 +4,7 @@ import io.koraframework.common.annotation.Tag; import io.koraframework.http.client.common.response.mapper.JsonHttpClientResponseMapper; import io.koraframework.http.common.HttpResponseEntity; +import io.koraframework.http.common.body.HttpBodyInput; import io.koraframework.json.common.JsonReader; import io.koraframework.json.common.annotation.Json; @@ -40,7 +41,12 @@ default HttpClientResponseMapper httpClientResponseByteBufferMapper( } @DefaultComponent - default HttpClientResponseMapper> httpClientResponsEentityResponseMapper(HttpClientResponseMapper mapper) { + default HttpClientResponseMapper httpClientResponseBodyInputMapper() { + return HttpClientResponse::body; + } + + @DefaultComponent + default HttpClientResponseMapper> httpClientResponseEntityResponseMapper(HttpClientResponseMapper mapper) { return response -> HttpResponseEntity.of(response.code(), response.headers().toMutable(), mapper.apply(response)); } @@ -50,7 +56,7 @@ default HttpClientResponseMapper> httpClientResponseJs return response -> HttpResponseEntity.of(response.code(), response.headers().toMutable(), delegate.apply(response)); } - @Tag(Json.class) + @Json @DefaultComponent default JsonHttpClientResponseMapper httpClientResponseJsonMapper(JsonReader reader) { return new JsonHttpClientResponseMapper<>(reader); diff --git a/http/http-server-common/src/main/java/io/koraframework/http/server/common/request/mapper/HttpServerRequestMapperModule.java b/http/http-server-common/src/main/java/io/koraframework/http/server/common/request/mapper/HttpServerRequestMapperModule.java index b94da8d2c..ff7e69052 100644 --- a/http/http-server-common/src/main/java/io/koraframework/http/server/common/request/mapper/HttpServerRequestMapperModule.java +++ b/http/http-server-common/src/main/java/io/koraframework/http/server/common/request/mapper/HttpServerRequestMapperModule.java @@ -1,6 +1,7 @@ package io.koraframework.http.server.common.request.mapper; import io.koraframework.common.annotation.DefaultComponent; +import io.koraframework.http.common.body.HttpBodyInput; import io.koraframework.http.common.form.FormMultipart; import io.koraframework.http.common.form.FormUrlEncoded; import io.koraframework.http.server.common.request.HttpServerRequest; @@ -19,6 +20,11 @@ default HttpServerRequestMapper noopHttpServerRequestMapper() return (r) -> r; } + @DefaultComponent + default HttpServerRequestMapper httpBodyInputHttpServerRequestMapper() { + return HttpServerRequest::body; + } + @DefaultComponent default HttpServerRequestMapper byteBufferHttpServerRequestMapper() { return (r) -> { diff --git a/http/http-server-common/src/main/java/io/koraframework/http/server/common/response/mapper/HttpServerResponseMapperModule.java b/http/http-server-common/src/main/java/io/koraframework/http/server/common/response/mapper/HttpServerResponseMapperModule.java index 2b6cd4cad..74a0faea8 100644 --- a/http/http-server-common/src/main/java/io/koraframework/http/server/common/response/mapper/HttpServerResponseMapperModule.java +++ b/http/http-server-common/src/main/java/io/koraframework/http/server/common/response/mapper/HttpServerResponseMapperModule.java @@ -3,6 +3,7 @@ import io.koraframework.common.annotation.DefaultComponent; import io.koraframework.http.common.HttpResponseEntity; import io.koraframework.http.common.body.HttpBody; +import io.koraframework.http.common.body.HttpBodyOutput; import io.koraframework.http.server.common.response.HttpServerResponse; import io.koraframework.http.server.common.response.HttpServerResponseMapper; import io.koraframework.json.common.JsonWriter; @@ -27,6 +28,11 @@ default HttpServerResponseMapper byteArrayHttpServerResponseMapper() { return (request, r) -> HttpServerResponse.of(200, HttpBody.octetStream(r)); } + @DefaultComponent + default HttpServerResponseMapper httpBodyOutputHttpServerResponseMapper() { + return (request, r) -> HttpServerResponse.of(200, r); + } + @DefaultComponent default HttpServerResponseMapper stringHttpServerResponseMapper() { return (request, r) -> HttpServerResponse.of(200, HttpBody.plaintext(r)); diff --git a/openapi/openapi-generator/build.gradle b/openapi/openapi-generator/build.gradle index cea60693c..6fcc2f67e 100644 --- a/openapi/openapi-generator/build.gradle +++ b/openapi/openapi-generator/build.gradle @@ -81,6 +81,7 @@ sourceSets { addOpenapiDir("petstoreV3_request_parameters") addOpenapiDir("petstoreV3_responses") addOpenapiDir("petstoreV3_same_response_model") + addOpenapiDir("petstoreV3_bare_object") addOpenapiDir("petstoreV3_security_all") addOpenapiDir("petstoreV3_security_all_auth_arg") addOpenapiDir("petstoreV3_security_multi") diff --git a/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/AbstractGenerator.java b/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/AbstractGenerator.java index c15d1cc76..6ec9f11a1 100644 --- a/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/AbstractGenerator.java +++ b/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/AbstractGenerator.java @@ -60,6 +60,7 @@ public static class Classes { public static final ClassName httpBody = ClassName.get("io.koraframework.http.common.body", "HttpBody"); public static final ClassName formMultipart = ClassName.get("io.koraframework.http.common.form", "FormMultipart"); public static final ClassName formPart = formMultipart.nestedClass("FormPart"); + public static final ClassName httpBodyInput = ClassName.get("io.koraframework.http.common.body", "HttpBodyInput"); public static final ClassName httpBodyOutput = ClassName.get("io.koraframework.http.common.body", "HttpBodyOutput"); // Client @@ -122,6 +123,7 @@ public static class Classes { public CodegenParams params; public String apiPackage; public String modelPackage; + public String outputFolder; public Map models; public Map typeMapping; public Map operationsByClassName; @@ -142,6 +144,9 @@ protected static String toVarName(String s) { } public TypeName asType(OperationsMap ctx, CodegenOperation operation, CodegenParameter param) { + if (param.isBodyParam && isBareObject(param)) { + return requestBodyType(); + } if (param.getSchema() != null) { return asType(param.getSchema()); } @@ -185,6 +190,9 @@ public TypeName asType(IJsonSchemaValidationProperties schema) { if (rs.isFile) { return ArrayTypeName.of(TypeName.BYTE); } + if (isBareObject(rs)) { + return responseBodyType(); + } } if (schema.getIsModel() && schema instanceof CodegenModel c) { return ClassName.get(modelPackage, c.getClassname()); @@ -199,6 +207,9 @@ public TypeName asType(IJsonSchemaValidationProperties schema) { return ParameterizedTypeName.get(ClassName.get(List.class), asType(schema.getItems()).box()); } if (schema.getIsMap()) { + if (schema.getAdditionalProperties() == null) { + return ClassName.get(Object.class); + } return ParameterizedTypeName.get(ClassName.get(Map.class), ClassName.get(String.class), asType(schema.getAdditionalProperties()).box()); } if (schema.getIsModel()) { @@ -235,6 +246,9 @@ public TypeName asType(IJsonSchemaValidationProperties schema) { if (schema instanceof CodegenParameter p && p.isEnumRef) { return ClassName.get(modelPackage, schema.getDataType()); } + if ("Object".equals(schema.getDataType()) || schema instanceof CodegenProperty p && p.isFreeFormObject) { + return ClassName.get(Object.class); + } if (schema.getIsLong()) { return TypeName.LONG; } @@ -287,4 +301,30 @@ public TypeName asType(IJsonSchemaValidationProperties schema) { } throw new IllegalArgumentException(schema.toString()); } + + protected TypeName requestBodyType() { + if (params.rawBodyMode == CodegenParams.RawBodyMode.BYTES) { + return ArrayTypeName.of(TypeName.BYTE); + } + return params.codegenMode.isClient() + ? Classes.httpBodyOutput + : Classes.httpBodyInput; + } + + protected TypeName responseBodyType() { + if (params.rawBodyMode == CodegenParams.RawBodyMode.BYTES) { + return ArrayTypeName.of(TypeName.BYTE); + } + return params.codegenMode.isClient() + ? Classes.httpBodyInput + : Classes.httpBodyOutput; + } + + protected boolean isBareObject(IJsonSchemaValidationProperties schema) { + return "Object".equals(schema.getDataType()) + || schema.getIsMap() && schema.getAdditionalProperties() == null + || schema instanceof CodegenProperty p && p.isFreeFormObject + || schema instanceof CodegenParameter cp && cp.isFreeFormObject + || schema instanceof CodegenResponse r && r.isFreeFormObject; + } } diff --git a/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/CodegenParams.java b/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/CodegenParams.java index 650a9dfa5..3b809316e 100644 --- a/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/CodegenParams.java +++ b/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/CodegenParams.java @@ -29,6 +29,7 @@ public class CodegenParams { public static final String IMPLICIT_HEADERS = "implicitHeaders"; public static final String IMPLICIT_HEADERS_REGEX = "implicitHeadersRegex"; public static final String FORCE_INCLUDE_OPTIONAL = "forceIncludeOptional"; + public static final String RAW_BODY_MODE = "rawBodyMode"; public CodegenMode codegenMode = CodegenMode.JAVA_CLIENT; public boolean enableValidation = false; @@ -47,6 +48,7 @@ public class CodegenParams { public boolean implicitHeaders = false; public @Nullable Pattern implicitHeadersRegex = null; public boolean forceIncludeOptional = false; + public RawBodyMode rawBodyMode = RawBodyMode.BYTES; static List cliOptions() { var cliOptions = new ArrayList(); @@ -65,6 +67,7 @@ static List cliOptions() { cliOptions.add(CliOption.newString(PREFIX_PATH, "Path prefix for HTTP Server controllers")); cliOptions.add(CliOption.newString(DELEGATE_METHOD_BODY_MODE, "Delegate method generation mode")); cliOptions.add(CliOption.newString(FORCE_INCLUDE_OPTIONAL, "If enabled forces Nullable and NonRequired fields to be included ALWAYS even if null, can't be enabled with enableJsonNullable simultaneously")); + cliOptions.add(CliOption.newString(RAW_BODY_MODE, "Bare object request and response body mode (one of BYTES, RAW)")); return cliOptions; } @@ -143,6 +146,18 @@ static CodegenParams parse(Map additionalProperties) { if (additionalProperties.containsKey(FORCE_INCLUDE_OPTIONAL)) { params.forceIncludeOptional = Boolean.parseBoolean(additionalProperties.get(FORCE_INCLUDE_OPTIONAL).toString()); } + if (additionalProperties.containsKey(RAW_BODY_MODE)) { + params.rawBodyMode = RawBodyMode.of(additionalProperties.get(RAW_BODY_MODE).toString()); + } return params; } + + public enum RawBodyMode { + BYTES, + RAW; + + public static RawBodyMode of(String value) { + return RawBodyMode.valueOf(value.toUpperCase(Locale.ROOT)); + } + } } diff --git a/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/KoraCodegen.java b/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/KoraCodegen.java index 556e95474..7699b5dad 100644 --- a/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/KoraCodegen.java +++ b/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/KoraCodegen.java @@ -50,7 +50,6 @@ public class KoraCodegen extends DefaultCodegen { private static final Logger LOGGER = LoggerFactory.getLogger(KoraCodegen.class); - public record TagClient(@Nullable String httpClientTag, @Nullable String telemetryTag) {} public record Interceptor(@Nullable String type, @Nullable Object tag) {} @@ -1879,6 +1878,7 @@ > Mustache.Lambda javaGen(T gen) { return (frag, out) -> { gen.apiPackage = apiPackage; gen.modelPackage = modelPackage; + gen.outputFolder = outputFolder; gen.params = params; gen.models = models; gen.operationsByClassName = operationsByClassName; @@ -1893,6 +1893,7 @@ > Mustache.Lambda kotlinGen(T gen) { return (frag, out) -> { gen.apiPackage = apiPackage; gen.modelPackage = modelPackage; + gen.outputFolder = outputFolder; gen.params = params; gen.models = models; gen.operationsByClassName = operationsByClassName; diff --git a/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/AbstractJavaGenerator.java b/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/AbstractJavaGenerator.java index 9ea31092b..1e82b23bb 100644 --- a/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/AbstractJavaGenerator.java +++ b/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/AbstractJavaGenerator.java @@ -109,7 +109,7 @@ protected ParameterSpec buildParameter(OperationsMap ctx, CodegenOperation opera .addMember("value", "$S", param.baseName) .build()); } - if (param.isBodyParam && KoraCodegen.isContentJson(param)) { + if (param.isBodyParam && KoraCodegen.isContentJson(param) && !isBareObject(param)) { b.addAnnotation(jsonAnnotation()); } if (params.codegenMode.isServer() && params.enableValidation) { @@ -202,12 +202,13 @@ protected CodeBlock buildMethodJavadoc(OperationsMap ctx, CodegenOperation opera var b = CodeBlock.builder(); b.add(operation.httpMethod + " " + operation.path); if (operation.summary != null) { - b.add(": " + operation.summary); + b.add(" : " + operation.summary); } b.add("\n"); if (operation.notes != null) { b.add(operation.notes).add("\n"); } + b.add("\n"); for (var param : operation.allParams) { if (!param.isFormParam) { b.add("@param ").add(param.paramName).add(" "); @@ -228,6 +229,20 @@ protected CodeBlock buildMethodJavadoc(OperationsMap ctx, CodegenOperation opera b.add("\n"); } } + if (!operation.responses.isEmpty()) { + b.add("@return "); + for (var i = 0; i < operation.responses.size(); i++) { + if (i > 0) { + b.add(" or "); + } + var response = operation.responses.get(i); + b.add(Objects.requireNonNullElse(response.message, "")); + b.add(" (status code "); + b.add(response.isDefault ? "default" : response.code); + b.add(")"); + } + b.add("\n"); + } if (operation.isDeprecated) { b.add("@deprecated\n"); } diff --git a/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/ClientApiGenerator.java b/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/ClientApiGenerator.java index e39cae08b..cdd7215d8 100644 --- a/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/ClientApiGenerator.java +++ b/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/ClientApiGenerator.java @@ -8,6 +8,8 @@ import org.openapitools.codegen.model.OperationsMap; import javax.lang.model.element.Modifier; +import java.io.IOException; +import java.nio.file.Path; import java.util.List; public class ClientApiGenerator extends AbstractJavaGenerator { @@ -28,7 +30,7 @@ public JavaFile generate(OperationsMap ctx) { .filter(p -> !(p.isHeaderParam && operation.implicitHeadersParams.stream().anyMatch(h -> p.paramName.equals(h.paramName)))) .toList(); if (!optionalParams.isEmpty()) { - b.addType(buildJavaClientApiOptionalParams(ctx, operation, optionalParams)); + writeJavaClientApiOptionalParams(ctx, operation, optionalParams); b.addMethod(buildRequiredArgsCall(ctx, operation, optionalParams)); b.addMethod(buildRequiredArgsWithArgsCall(ctx, operation, optionalParams)); } @@ -135,26 +137,32 @@ private MethodSpec buildRequiredArgsWithArgsCall(OperationsMap ctx, CodegenOpera } paramsCounter++; } - b.addParameter(ClassName.get(apiPackage, ctx.get("classname").toString(), StringUtils.capitalize(operation.operationId) + "OptArgs"), "optionalArguments"); + b.addParameter(optionalArgsClassName(ctx, operation), "optionalArguments"); return b.addCode(");\n").build(); } + private void writeJavaClientApiOptionalParams(OperationsMap ctx, CodegenOperation operation, List optionalParams) { + try { + JavaFile.builder(apiPackage, buildJavaClientApiOptionalParams(ctx, operation, optionalParams)) + .build() + .writeTo(Path.of(outputFolder)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + private TypeSpec buildJavaClientApiOptionalParams(OperationsMap ctx, CodegenOperation operation, List optionalParams) { - var b = MethodSpec.constructorBuilder(); + var constructor = MethodSpec.constructorBuilder() + .addModifiers(Modifier.PRIVATE); if (operation.isDeprecated) { - b.addAnnotation(Deprecated.class); + constructor.addAnnotation(Deprecated.class); } - for (var optionalParam : optionalParams) { - var type = asType(ctx, operation, optionalParam).box().annotated(AnnotationSpec.builder(Classes.nullable).build()); - b.addParameter(ParameterSpec.builder(type, optionalParam.paramName) - .build() - ); - } - var recordClassName = ClassName.get(apiPackage, ctx.get("classname").toString(), StringUtils.capitalize(operation.operationId) + "OptArgs"); + var recordClassName = optionalArgsClassName(ctx, operation); + + var typeSpec = TypeSpec.classBuilder(recordClassName.simpleName()) + .addAnnotation(generated()) + .addModifiers(Modifier.PUBLIC, Modifier.FINAL); - var typeSpec = TypeSpec.recordBuilder(recordClassName) - .addModifiers(Modifier.PUBLIC, Modifier.STATIC) - .recordConstructor(b.build()); var empty = MethodSpec.methodBuilder("empty") .addModifiers(Modifier.PUBLIC, Modifier.STATIC) .returns(recordClassName) @@ -188,6 +196,20 @@ private TypeSpec buildJavaClientApiOptionalParams(OperationsMap ctx, CodegenOper defaults.addCode(");\n"); typeSpec.addMethod(defaults.build()); + var accessors = new java.util.ArrayList(); + for (var optionalParam : optionalParams) { + var type = asType(ctx, operation, optionalParam).box().annotated(AnnotationSpec.builder(Classes.nullable).build()); + constructor.addParameter(ParameterSpec.builder(type, optionalParam.paramName).build()); + constructor.addStatement("this.$N = $N", optionalParam.paramName, optionalParam.paramName); + typeSpec.addField(FieldSpec.builder(type, optionalParam.paramName, Modifier.PRIVATE).build()); + accessors.add(MethodSpec.methodBuilder(optionalParam.paramName) + .addModifiers(Modifier.PUBLIC) + .returns(type) + .addStatement("return this.$N", optionalParam.paramName) + .build()); + } + typeSpec.addMethod(constructor.build()); + accessors.forEach(typeSpec::addMethod); for (var optionalParam : optionalParams) { var type = asType(ctx, operation, optionalParam).box(); @@ -195,21 +217,18 @@ private TypeSpec buildJavaClientApiOptionalParams(OperationsMap ctx, CodegenOper .returns(recordClassName) .addModifiers(Modifier.PUBLIC) .addParameter(type, optionalParam.paramName) - .addCode("return new $T(", recordClassName); - for (int i = 0; i < optionalParams.size(); i++) { - if (i > 0) { - wither.addCode(", "); - } - var p = optionalParams.get(i); - wither.addCode(p.paramName); - } - wither.addCode(");\n"); + .addStatement("this.$N = $N", optionalParam.paramName, optionalParam.paramName) + .addStatement("return this"); typeSpec.addMethod(wither.build()); } return typeSpec.build(); } + private ClassName optionalArgsClassName(OperationsMap ctx, CodegenOperation operation) { + return ClassName.get(apiPackage, ctx.get("classname").toString() + StringUtils.capitalize(operation.operationId) + "OptArgs"); + } + private MethodSpec buildMethod(OperationsMap ctx, CodegenOperation operation) { var tag = ctx.get("baseName").toString(); diff --git a/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/ClientResponseMapperGenerator.java b/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/ClientResponseMapperGenerator.java index fa1a65006..9c33d2797 100644 --- a/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/ClientResponseMapperGenerator.java +++ b/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/ClientResponseMapperGenerator.java @@ -39,7 +39,7 @@ private TypeSpec responseMapper(OperationsMap ctx, ClassName mappers, CodegenOpe var mapperType = ParameterizedTypeName.get(Classes.httpClientResponseMapper, asType(response)); b.addField(mapperType, "delegate", Modifier.PRIVATE, Modifier.FINAL); var mapperParam = ParameterSpec.builder(mapperType, "delegate"); - if (isContentJson(response.getContent())) { + if (isContentJson(response.getContent()) && !isBareObject(response)) { mapperParam.addAnnotation(jsonAnnotation()); } constructor = MethodSpec.constructorBuilder() diff --git a/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/ModelGenerator.java b/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/ModelGenerator.java index 2f65665cb..7cd5332f2 100644 --- a/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/ModelGenerator.java +++ b/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/ModelGenerator.java @@ -1,14 +1,20 @@ package io.koraframework.openapi.generator.javagen; import com.palantir.javapoet.*; +import org.apache.commons.lang3.StringUtils; import org.openapitools.codegen.CodegenModel; import org.openapitools.codegen.CodegenProperty; import org.openapitools.codegen.model.ModelsMap; import javax.lang.model.element.Modifier; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.file.Path; import java.util.*; public class ModelGenerator extends AbstractJavaGenerator { + private record Field(String name, String jsonName, TypeName type, boolean required, boolean nullable, String description, String defaultValue, String example) {} + @Override public JavaFile generate(ModelsMap ctx) { var models = ctx.getModels(); @@ -25,6 +31,8 @@ public JavaFile generate(ModelsMap ctx) { type = buildRecord(ctx, model); } + writeEnumMapperModules(ctx); + return JavaFile.builder(modelPackage, type).build(); } @@ -59,10 +67,14 @@ private TypeSpec buildSealed(ModelsMap ctx, CodegenModel model) { private TypeSpec buildRecord(ModelsMap ctx, CodegenModel model) { var b = TypeSpec.recordBuilder(model.getClassname()) .addAnnotation(generated()) - .addModifiers(Modifier.PUBLIC) - .addJavadoc(Objects.requireNonNullElse(model.description, "") + "\n"); + .addModifiers(Modifier.PUBLIC); + if (model.description == null || model.description.isBlank()) { + b.addJavadoc("$L\n", model.classname); + } else { + b.addJavadoc("$L - $L\n", model.classname, model.description); + } for (var field : model.allVars) { - b.addJavadoc("@param $N $L, $L\n", field.name, Objects.requireNonNullElse(field.description, field.baseName), field.example == null ? "" : "(example: " + field.example + ")"); + b.addJavadoc("@param $N $L$L\n", field.name, Objects.requireNonNullElse(field.description, field.baseName), fieldJavadocMetadata(field)); } if (params.enableValidation) { b.addAnnotation(Classes.valid); @@ -139,7 +151,6 @@ private TypeSpec buildRecord(ModelsMap ctx, CodegenModel model) { b.addSuperinterfaces(superinterfaces); var constructor = MethodSpec.constructorBuilder() .addModifiers(Modifier.PUBLIC); - record Field(String name, String jsonName, TypeName type, boolean required, boolean nullable) {} var fields = new ArrayList(); for (var field : model.allVars) { if (parentFields.containsKey(field.name)) { @@ -165,13 +176,16 @@ record Field(String name, String jsonName, TypeName type, boolean required, bool enumModel.name = enumSource.enumName; enumModel.allowableValues = enumSource.allowableValues; enumModel.dataType = enumSource.dataType; + enumModel.description = enumSource.description; + enumModel.vendorExtensions = enumSource.vendorExtensions; enumModel.isString = enumSource.isString; enumModel.isLong = enumSource.isLong; enumModel.isInteger = enumSource.isInteger; - var enumTypeSpec = buildEnum(ctx, enumModel); + var enumClassName = ClassName.get(modelPackage, model.getClassname(), enumModel.name); + var enumTypeSpec = buildEnum(enumModel); b.addType(enumTypeSpec); - fieldType = ClassName.get(modelPackage, model.getClassname(), enumModel.name); + fieldType = enumClassName; if (field.isNullable && !field.required) { fieldType = ParameterizedTypeName.get(Classes.jsonNullable, fieldType); } else if (field.isNullable || !field.required) { @@ -186,7 +200,7 @@ record Field(String name, String jsonName, TypeName type, boolean required, bool if (validation != null) { p.addAnnotation(validation); } - fields.add(new Field(field.name, field.baseName, fieldType, field.required, field.isNullable)); + fields.add(new Field(field.name, field.baseName, fieldType, field.required, field.isNullable, field.description, field.defaultValue, field.example)); if (field.required && field.isNullable) { p.addAnnotation(AnnotationSpec.builder(Classes.jsonInclude).addMember("value", "$T.ALWAYS", Classes.jsonInclude.nestedClass("IncludeType")).build()); } @@ -248,10 +262,73 @@ record Field(String name, String jsonName, TypeName type, boolean required, bool c.addCode(");\n"); b.addMethod(c.build()); } + for (var field : fields) { + b.addMethod(buildWithMethod(model, fields, field)); + } return b.recordConstructor(constructor.build()).build(); } + private MethodSpec buildWithMethod(CodegenModel model, List fields, Field field) { + var method = MethodSpec.methodBuilder("with" + capitalize(field.name())) + .addModifiers(Modifier.PUBLIC) + .addParameter(field.type(), field.name()) + .returns(ClassName.get(modelPackage, model.getClassname())); + buildWithMethodJavadoc(method, field); + method.addCode("if ($L) return this; ", fieldEquals(field)); + method.addCode("return new $T(", ClassName.get(modelPackage, model.getClassname())); + for (var i = 0; i < fields.size(); i++) { + if (i > 0) { + method.addCode(", "); + } + var current = fields.get(i); + method.addCode("$N", current.name().equals(field.name()) ? field.name() : "this." + current.name()); + } + method.addCode(");\n"); + return method.build(); + } + + private String fieldJavadocMetadata(CodegenProperty field) { + var metadata = new ArrayList(); + if (field.defaultValue != null) { + metadata.add("default: " + field.defaultValue); + } + if (field.example != null) { + metadata.add("example: " + field.example); + } + return metadata.isEmpty() ? "" : " (" + String.join(", ", metadata) + ")"; + } + + private void buildWithMethodJavadoc(MethodSpec.Builder method, Field field) { + var javadoc = new ArrayList(); + if (field.description() != null && !field.description().equals(field.jsonName()) && !field.description().equals(field.name())) { + javadoc.add(field.description()); + } + if (field.defaultValue() != null) { + javadoc.add("default: " + field.defaultValue()); + } + if (field.example() != null) { + javadoc.add("example: " + field.example()); + } + if (!javadoc.isEmpty()) { + method.addJavadoc("($L)", String.join(", ", javadoc)); + } + } + + private CodeBlock fieldEquals(Field field) { + var type = field.type().withoutAnnotations(); + if (type.equals(TypeName.FLOAT)) { + return CodeBlock.of("$T.compare(this.$N, $N) == 0", Float.class, field.name(), field.name()); + } + if (type.equals(TypeName.DOUBLE)) { + return CodeBlock.of("$T.compare(this.$N, $N) == 0", Double.class, field.name(), field.name()); + } + if (type.isPrimitive()) { + return CodeBlock.of("this.$N == $N", field.name(), field.name()); + } + return CodeBlock.of("$T.equals(this.$N, $N)", Objects.class, field.name(), field.name()); + } + private TypeName fieldType(CodegenProperty field) { var type = asType(field); if (field.isNullable && !field.required) { @@ -264,17 +341,27 @@ private TypeName fieldType(CodegenProperty field) { } private TypeSpec buildEnum(ModelsMap ctx, CodegenModel model) { - var contextModel = ctx.getModels().getFirst().getModel(); - var enumClassName = contextModel == model ? ClassName.get(modelPackage, model.name) : ClassName.get(modelPackage, contextModel.classname, model.name); - var b = TypeSpec.enumBuilder(enumClassName) + return buildEnum(model); + } + + private TypeSpec buildEnum(CodegenModel model) { + var b = TypeSpec.enumBuilder(model.name) .addAnnotation(generated()) .addModifiers(Modifier.PUBLIC); + if (model.description != null && !model.description.isBlank()) { + b.addJavadoc("$L\n", model.description); + } @SuppressWarnings("unchecked") var enumVars = (List>) model.allowableValues.get("enumVars"); - for (var enumVar : enumVars) { + for (var i = 0; i < enumVars.size(); i++) { + var enumVar = enumVars.get(i); var enumName = enumVar.get("name").toString(); - b.addEnumConstant(enumName, TypeSpec.anonymousClassBuilder("Constants.$L", enumName) - .build()); + var enumConstant = TypeSpec.anonymousClassBuilder("$L", enumVar.get("value")); + var description = enumValueDescription(model, enumVar, i); + if (description != null && !description.isBlank()) { + enumConstant.addJavadoc("$L\n", description); + } + b.addEnumConstant(enumName, enumConstant.build()); } b.addField(enumValueType(model), "value", Modifier.PRIVATE, Modifier.FINAL); b.addMethod(MethodSpec.constructorBuilder() @@ -293,97 +380,207 @@ private TypeSpec buildEnum(ModelsMap ctx, CodegenModel model) { .returns(String.class) .addStatement("return String.valueOf(value)") .build()); - var constants = TypeSpec.classBuilder("Constants") - .addModifiers(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL) - .addAnnotation(generated()); - for (var enumVar : enumVars) { - var enumName = enumVar.get("name").toString(); - constants.addField(FieldSpec.builder(enumValueType(model), enumName, Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL).initializer("$L", enumVar.get("value")).build()); + return b.build(); + } + + private void writeEnumMapperModules(ModelsMap ctx) { + var model = ctx.getModels().getFirst().getModel(); + var modules = new LinkedHashMap(); + if (model.isEnum) { + var enumClassName = ClassName.get(modelPackage, model.name); + modules.put(enumMapperModuleName(enumClassName), buildEnumMapperModuleFile(enumClassName, model)); + } + if (model.discriminator != null) { + return; + } + for (var field : model.allVars) { + if (!field.isInnerEnum) { + continue; + } + var enumModel = enumModel(field); + var enumClassName = ClassName.get(modelPackage, model.getClassname(), enumModel.name); + modules.put(enumMapperModuleName(enumClassName), buildEnumMapperModuleFile(enumClassName, enumModel)); } - b.addType(constants.build()); + for (var module : modules.values()) { + try { + module.writeTo(Path.of(outputFolder)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } - b.addType(TypeSpec.classBuilder("JsonWriter") - .addAnnotation(generated()) - .addModifiers(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL) - .addAnnotation(Classes.component) - .addSuperinterface(ParameterizedTypeName.get(Classes.jsonWriter, enumClassName)) - .addField(ParameterizedTypeName.get(Classes.enumJsonWriter, enumClassName, enumValueType(model)), "delegate", Modifier.PRIVATE, Modifier.FINAL) - .addMethod(MethodSpec.constructorBuilder() - .addModifiers(Modifier.PUBLIC) - .addParameter(ParameterizedTypeName.get(Classes.jsonWriter, enumValueType(model)), "delegate") - .addStatement("this.delegate = new $T<>($T.values(), $T::getValue, delegate)", Classes.enumJsonWriter, enumClassName, enumClassName) - .build()) - .addMethod(MethodSpec.methodBuilder("write") - .addModifiers(Modifier.PUBLIC) - .addAnnotation(Override.class) - .addParameter(Classes.jsonGenerator, "gen") - .addParameter(enumClassName.annotated(AnnotationSpec.builder(Classes.nullable).build()), "value") - .addStatement("this.delegate.write(gen, value)") - .build()) - .build()); + private CodegenModel enumModel(CodegenProperty field) { + var enumModel = new CodegenModel(); + var enumSource = field; + if (field.isContainer) { + enumSource = field.items; + } + enumModel.name = enumSource.enumName; + enumModel.allowableValues = enumSource.allowableValues; + enumModel.dataType = enumSource.dataType; + enumModel.description = enumSource.description; + enumModel.vendorExtensions = enumSource.vendorExtensions; + enumModel.isString = enumSource.isString; + enumModel.isLong = enumSource.isLong; + enumModel.isInteger = enumSource.isInteger; + return enumModel; + } + + private String enumValueDescription(CodegenModel model, Map enumVar, int index) { + var enumDescription = enumVar.get("description"); + if (enumDescription != null) { + return enumDescription.toString(); + } + enumDescription = enumVar.get("enumDescription"); + if (enumDescription != null) { + return enumDescription.toString(); + } + if (model.vendorExtensions == null) { + return null; + } + for (var extension : List.of("x-enum-descriptions", "x-enumDescriptions", "x-enum-var-descriptions")) { + var descriptions = model.vendorExtensions.get(extension); + var description = enumValueDescription(descriptions, enumVar, index); + if (description != null) { + return description; + } + } + return null; + } - b.addType(TypeSpec.classBuilder("JsonReader") + private String enumValueDescription(Object descriptions, Map enumVar, int index) { + if (descriptions instanceof List list) { + if (index >= list.size()) { + return null; + } + var description = list.get(index); + return description == null ? null : description.toString(); + } + if (descriptions instanceof Map map) { + var description = map.get(enumVar.get("value")); + if (description == null) { + description = map.get(enumVar.get("name")); + } + return description == null ? null : description.toString(); + } + return null; + } + + private JavaFile buildEnumMapperModuleFile(ClassName enumClassName, CodegenModel model) { + var module = buildEnumMapperModule(enumMapperModuleName(enumClassName), enumClassName, model); + return JavaFile.builder(modelPackage, module).build(); + } + + private String enumMapperModuleName(ClassName enumClassName) { + return "$" + String.join("", enumClassName.simpleNames()) + "MapperModule"; + } + + private TypeSpec buildEnumMapperModule(String moduleName, ClassName enumClassName, CodegenModel model) { + var enumValueType = enumValueType(model); + var enumSimpleName = enumClassName.simpleName(); + var module = TypeSpec.interfaceBuilder(moduleName) .addAnnotation(generated()) - .addModifiers(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL) - .addAnnotation(Classes.component) - .addSuperinterface(ParameterizedTypeName.get(Classes.jsonReader, enumClassName)) - .addField(ParameterizedTypeName.get(Classes.enumJsonReader, enumClassName, enumValueType(model)), "delegate", Modifier.PRIVATE, Modifier.FINAL) - .addMethod(MethodSpec.constructorBuilder() - .addModifiers(Modifier.PUBLIC) - .addParameter(ParameterizedTypeName.get(Classes.jsonReader, enumValueType(model)), "delegate") - .addStatement("this.delegate = new $T<>($T.values(), $T::getValue, delegate)", Classes.enumJsonReader, enumClassName, enumClassName) - .build()) - .addMethod(MethodSpec.methodBuilder("read") - .addModifiers(Modifier.PUBLIC) - .addAnnotation(Override.class) - .addParameter(Classes.jsonParser, "parser") - .addStatement("return this.delegate.read(parser)") - .returns(enumClassName.annotated(AnnotationSpec.builder(Classes.nullable).build())) - .build()) - .build() - ); + .addAnnotation(Classes.module) + .addModifiers(Modifier.PUBLIC); + + var jsonWriter = MethodSpec.methodBuilder(StringUtils.uncapitalize(enumSimpleName) + "JsonWriter") + .addModifiers(Modifier.PUBLIC, Modifier.DEFAULT) + .addAnnotation(Classes.defaultComponent) + .returns(ParameterizedTypeName.get(Classes.jsonWriter, enumClassName)); + if (isInlineEnumJsonValueType(enumValueType)) { + jsonWriter.addStatement("return new $T<>($T.values(), $T::getValue, $L)", Classes.enumJsonWriter, enumClassName, enumClassName, enumJsonWriter(enumValueType)); + } else { + jsonWriter + .addParameter(ParameterizedTypeName.get(Classes.jsonWriter, enumValueType), "delegate") + .addStatement("return new $T<>($T.values(), $T::getValue, delegate)", Classes.enumJsonWriter, enumClassName, enumClassName); + } + module.addMethod(jsonWriter.build()); + + var jsonReader = MethodSpec.methodBuilder(StringUtils.uncapitalize(enumSimpleName) + "JsonReader") + .addModifiers(Modifier.PUBLIC, Modifier.DEFAULT) + .addAnnotation(Classes.defaultComponent) + .returns(ParameterizedTypeName.get(Classes.jsonReader, enumClassName)); + if (isInlineEnumJsonValueType(enumValueType)) { + jsonReader.addStatement("return new $T<>($T.values(), $T::getValue, $L)", Classes.enumJsonReader, enumClassName, enumClassName, enumJsonReader(enumValueType)); + } else { + jsonReader + .addParameter(ParameterizedTypeName.get(Classes.jsonReader, enumValueType), "delegate") + .addStatement("return new $T<>($T.values(), $T::getValue, delegate)", Classes.enumJsonReader, enumClassName, enumClassName); + } + module.addMethod(jsonReader.build()); if (params.codegenMode.isClient()) { - b.addType(TypeSpec.classBuilder("StringParameterConverter") - .addAnnotation(generated()) - .addModifiers(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL) - .addAnnotation(Classes.component) - .addSuperinterface(ParameterizedTypeName.get(Classes.stringParameterConverter, enumClassName)) - .addField(ParameterizedTypeName.get(Classes.enumStringParameterConverter, enumClassName), "delegate", Modifier.PRIVATE, Modifier.FINAL) - .addMethod(MethodSpec.constructorBuilder() - .addModifiers(Modifier.PUBLIC) - .addStatement("this.delegate = new $T<>($T.values(), v -> String.valueOf(v.getValue()))", Classes.enumStringParameterConverter, enumClassName) - .build()) - .addMethod(MethodSpec.methodBuilder("convert") - .addModifiers(Modifier.PUBLIC) - .addAnnotation(Override.class) - .addParameter(enumClassName, "value") - .addStatement("return this.delegate.convert(value)") - .returns(String.class) - .build()) + module.addMethod(MethodSpec.methodBuilder(StringUtils.uncapitalize(enumSimpleName) + "StringParameterConverter") + .addModifiers(Modifier.PUBLIC, Modifier.DEFAULT) + .addAnnotation(Classes.defaultComponent) + .returns(ParameterizedTypeName.get(Classes.stringParameterConverter, enumClassName)) + .addStatement("return new $T<>($T.values(), v -> String.valueOf(v.getValue()))", Classes.enumStringParameterConverter, enumClassName) .build()); } else { - b.addType(TypeSpec.classBuilder("StringParameterReader") - .addAnnotation(generated()) - .addModifiers(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL) - .addAnnotation(Classes.component) - .addSuperinterface(ParameterizedTypeName.get(Classes.stringParameterReader, enumClassName)) - .addField(ParameterizedTypeName.get(Classes.enumStringParameterReader, enumClassName), "delegate", Modifier.PRIVATE, Modifier.FINAL) - .addMethod(MethodSpec.constructorBuilder() - .addModifiers(Modifier.PUBLIC) - .addStatement("this.delegate = new $T<>($T.values(), v -> String.valueOf(v.getValue()))", Classes.enumStringParameterReader, enumClassName) - .build()) - .addMethod(MethodSpec.methodBuilder("read") - .addModifiers(Modifier.PUBLIC) - .addAnnotation(Override.class) - .addParameter(String.class, "value") - .addStatement("return this.delegate.read(value)") - .returns(enumClassName) - .build()) + module.addMethod(MethodSpec.methodBuilder(StringUtils.uncapitalize(enumSimpleName) + "StringParameterReader") + .addModifiers(Modifier.PUBLIC, Modifier.DEFAULT) + .addAnnotation(Classes.defaultComponent) + .returns(ParameterizedTypeName.get(Classes.stringParameterReader, enumClassName)) + .addStatement("return new $T<>($T.values(), v -> String.valueOf(v.getValue()))", Classes.enumStringParameterReader, enumClassName) .build()); } - return b.build(); + return module.build(); + } + + private boolean isInlineEnumJsonValueType(TypeName enumValueType) { + return enumValueType.equals(ClassName.get(String.class)) + || enumValueType.equals(TypeName.INT.box()) + || enumValueType.equals(TypeName.LONG.box()); + } + + private CodeBlock enumJsonWriter(TypeName enumValueType) { + if (enumValueType.equals(ClassName.get(String.class))) { + return CodeBlock.of("(gen, object) -> {\n" + + " if (object == null) {\n" + + " gen.writeNull();\n" + + " } else {\n" + + " gen.writeString(object);\n" + + " }\n" + + "}"); + } + if (enumValueType.equals(TypeName.INT.box()) || enumValueType.equals(TypeName.LONG.box())) { + return CodeBlock.of("(gen, object) -> {\n" + + " if (object == null) {\n" + + " gen.writeNull();\n" + + " } else {\n" + + " gen.writeNumber(object);\n" + + " }\n" + + "}"); + } + throw new RuntimeException("Illegal enum value type: " + enumValueType); + } + + private CodeBlock enumJsonReader(TypeName enumValueType) { + var streamReadException = ClassName.get("tools.jackson.core.exc", "StreamReadException"); + if (enumValueType.equals(ClassName.get(String.class))) { + return CodeBlock.of("parser -> switch (parser.currentToken()) {\n" + + " case VALUE_NULL -> null;\n" + + " case VALUE_STRING -> parser.getString();\n" + + " default -> throw new $T(parser, $S + parser.currentToken());\n" + + "}", streamReadException, "Expecting VALUE_STRING token, got "); + } + if (enumValueType.equals(TypeName.INT.box())) { + return CodeBlock.of("parser -> switch (parser.currentToken()) {\n" + + " case VALUE_NULL -> null;\n" + + " case VALUE_NUMBER_INT -> parser.getIntValue();\n" + + " default -> throw new $T(parser, $S + parser.currentToken());\n" + + "}", streamReadException, "Expecting VALUE_NUMBER_INT token, got "); + } + if (enumValueType.equals(TypeName.LONG.box())) { + return CodeBlock.of("parser -> switch (parser.currentToken()) {\n" + + " case VALUE_NULL -> null;\n" + + " case VALUE_NUMBER_INT -> parser.getLongValue();\n" + + " default -> throw new $T(parser, $S + parser.currentToken());\n" + + "}", streamReadException, "Expecting VALUE_NUMBER_INT token, got "); + } + throw new RuntimeException("Illegal enum value type: " + enumValueType); } private TypeName enumValueType(CodegenModel model) { @@ -396,7 +593,22 @@ private TypeName enumValueType(CodegenModel model) { if (model.isInteger || "Integer".equals(model.dataType)) { return TypeName.INT.box(); } + if ("Boolean".equals(model.dataType)) { + return TypeName.BOOLEAN.box(); + } + if ("Float".equals(model.dataType)) { + return TypeName.FLOAT.box(); + } + if ("Double".equals(model.dataType)) { + return TypeName.DOUBLE.box(); + } + if ("BigDecimal".equals(model.dataType)) { + return ClassName.get(BigDecimal.class); + } + if (model.dataType != null && !model.dataType.isBlank()) { + return ClassName.bestGuess(model.dataType); + } - throw new RuntimeException("Illegal enum value type: " + model); + return ClassName.get(Object.class); } } diff --git a/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/ServerResponseMapperGenerator.java b/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/ServerResponseMapperGenerator.java index 246673e66..b22018c0d 100644 --- a/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/ServerResponseMapperGenerator.java +++ b/openapi/openapi-generator/src/main/java/io/koraframework/openapi/generator/javagen/ServerResponseMapperGenerator.java @@ -40,7 +40,7 @@ private TypeSpec buildMapper(OperationsMap ctx, CodegenOperation operation) { var mapperName = "response" + response.code + "Delegate"; b.addField(mapperType, mapperName, Modifier.PRIVATE, Modifier.FINAL); var param = ParameterSpec.builder(mapperType, mapperName); - if (KoraCodegen.isContentJson(response.getContent())) { + if (KoraCodegen.isContentJson(response.getContent()) && !isBareObject(response)) { param.addAnnotation(Classes.json); } constructor.addParameter(param.build()); diff --git a/openapi/openapi-generator/src/main/kotlin/io/koraframework/openapi/generator/kotlingen/AbstractKotlinGenerator.kt b/openapi/openapi-generator/src/main/kotlin/io/koraframework/openapi/generator/kotlingen/AbstractKotlinGenerator.kt index 1a4d295d0..8be89eeae 100644 --- a/openapi/openapi-generator/src/main/kotlin/io/koraframework/openapi/generator/kotlingen/AbstractKotlinGenerator.kt +++ b/openapi/openapi-generator/src/main/kotlin/io/koraframework/openapi/generator/kotlingen/AbstractKotlinGenerator.kt @@ -145,7 +145,7 @@ abstract class AbstractKotlinGenerator : AbstractGenerator .build() ) - param.isBodyParam && KoraCodegen.isContentJson(param) -> b.addAnnotation( + param.isBodyParam && KoraCodegen.isContentJson(param) && !isBareObject(param) -> b.addAnnotation( AnnotationSpec.builder(Classes.json.asKt()) .build() ) @@ -268,13 +268,17 @@ abstract class AbstractKotlinGenerator : AbstractGenerator com.palantir.javapoet.ClassName.get("java.util", "Map") -> Map::class.asClassName() com.palantir.javapoet.ClassName.get("java.util", "Set") -> Set::class.asClassName() com.palantir.javapoet.ClassName.get("java.lang", "String") -> String::class.asClassName() + com.palantir.javapoet.ClassName.get("java.lang", "Object") -> ANY + com.palantir.javapoet.ClassName.get("java.math", "BigDecimal") -> java.math.BigDecimal::class.asClassName() + com.palantir.javapoet.ClassName.get("io.koraframework.http.common.body", "HttpBodyInput") -> ClassName("io.koraframework.http.common.body", "HttpBodyInput") + com.palantir.javapoet.ClassName.get("io.koraframework.http.common.body", "HttpBodyOutput") -> ClassName("io.koraframework.http.common.body", "HttpBodyOutput") com.palantir.javapoet.TypeName.INT.box() -> INT com.palantir.javapoet.TypeName.LONG.box() -> LONG com.palantir.javapoet.TypeName.SHORT.box() -> SHORT com.palantir.javapoet.TypeName.BYTE.box() -> BYTE com.palantir.javapoet.TypeName.DOUBLE.box() -> DOUBLE - com.palantir.javapoet.TypeName.BOOLEAN.box() -> FLOAT - com.palantir.javapoet.TypeName.FLOAT.box() -> BOOLEAN + com.palantir.javapoet.TypeName.BOOLEAN.box() -> BOOLEAN + com.palantir.javapoet.TypeName.FLOAT.box() -> FLOAT else -> ClassName(packageName(), simpleNames()) } @@ -304,5 +308,3 @@ abstract class AbstractKotlinGenerator : AbstractGenerator protected fun jsonAnnotation() = AnnotationSpec.builder(Classes.json.asKt()).build() } - - diff --git a/openapi/openapi-generator/src/main/kotlin/io/koraframework/openapi/generator/kotlingen/ClientResponseMapperGenerator.kt b/openapi/openapi-generator/src/main/kotlin/io/koraframework/openapi/generator/kotlingen/ClientResponseMapperGenerator.kt index c7e933d6e..c8e75b799 100644 --- a/openapi/openapi-generator/src/main/kotlin/io/koraframework/openapi/generator/kotlingen/ClientResponseMapperGenerator.kt +++ b/openapi/openapi-generator/src/main/kotlin/io/koraframework/openapi/generator/kotlingen/ClientResponseMapperGenerator.kt @@ -33,7 +33,7 @@ class ClientResponseMapperGenerator : AbstractKotlinGenerator() { val mapperType = Classes.httpClientResponseMapper.asKt().parameterizedBy(asType(response).asKt()) b.addProperty(PropertySpec.builder("delegate", mapperType).initializer("delegate").build()) val mapperParam = ParameterSpec.builder("delegate", mapperType) - if (KoraCodegen.isContentJson(response.content)) { + if (KoraCodegen.isContentJson(response.content) && !isBareObject(response)) { mapperParam.addAnnotation(jsonAnnotation()) } constructor.addParameter(mapperParam.build()) diff --git a/openapi/openapi-generator/src/main/kotlin/io/koraframework/openapi/generator/kotlingen/ModelGenerator.kt b/openapi/openapi-generator/src/main/kotlin/io/koraframework/openapi/generator/kotlingen/ModelGenerator.kt index 08b2a3069..81610a1ad 100644 --- a/openapi/openapi-generator/src/main/kotlin/io/koraframework/openapi/generator/kotlingen/ModelGenerator.kt +++ b/openapi/openapi-generator/src/main/kotlin/io/koraframework/openapi/generator/kotlingen/ModelGenerator.kt @@ -137,6 +137,11 @@ class ModelGenerator : AbstractKotlinGenerator() { enumModel.isString = enumSource.isString enumModel.isLong = enumSource.isLong enumModel.isInteger = enumSource.isInteger + enumModel.isBoolean = enumSource.isBoolean + enumModel.isFloat = enumSource.isFloat + enumModel.isDouble = enumSource.isDouble + enumModel.isDecimal = enumSource.isDecimal + enumModel.isNumber = enumSource.isNumber val enumTypeSpec = buildEnum(ctx, enumModel) b.addType(enumTypeSpec) fieldType = ClassName(modelPackage, model.getClassname(), enumModel.name) @@ -383,7 +388,22 @@ class ModelGenerator : AbstractKotlinGenerator() { if (model.isInteger || "Integer" == model.dataType) { return INT } - throw RuntimeException("Illegal enum value type") + if ("Boolean" == model.dataType) { + return BOOLEAN + } + if ("Float" == model.dataType) { + return FLOAT + } + if ("Double" == model.dataType) { + return DOUBLE + } + if ("BigDecimal" == model.dataType) { + return java.math.BigDecimal::class.asClassName() + } + if (!model.dataType.isNullOrBlank()) { + return com.palantir.javapoet.ClassName.bestGuess(model.dataType).asKt() + } + return ANY } diff --git a/openapi/openapi-generator/src/main/kotlin/io/koraframework/openapi/generator/kotlingen/ServerResponseMappersGenerator.kt b/openapi/openapi-generator/src/main/kotlin/io/koraframework/openapi/generator/kotlingen/ServerResponseMappersGenerator.kt index e5c391ff1..0a4ed73e2 100644 --- a/openapi/openapi-generator/src/main/kotlin/io/koraframework/openapi/generator/kotlingen/ServerResponseMappersGenerator.kt +++ b/openapi/openapi-generator/src/main/kotlin/io/koraframework/openapi/generator/kotlingen/ServerResponseMappersGenerator.kt @@ -36,7 +36,7 @@ class ServerResponseMappersGenerator : AbstractKotlinGenerator() val mapperName = "response" + response.code + "Delegate" b.addProperty(PropertySpec.builder(mapperName, mapperType).initializer(mapperName).build()) val param = ParameterSpec.builder(mapperName, mapperType) - if (KoraCodegen.isContentJson(response.content)) { + if (KoraCodegen.isContentJson(response.content) && !isBareObject(response)) { param.addAnnotation(Classes.json.asKt()) } constructor.addParameter(param.build()) diff --git a/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/BaseOpenapiTest.java b/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/BaseOpenapiTest.java index cda14c840..cc2792248 100644 --- a/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/BaseOpenapiTest.java +++ b/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/BaseOpenapiTest.java @@ -6,8 +6,10 @@ import javax.annotation.Nullable; import java.io.File; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; @@ -24,6 +26,8 @@ public static final class Options { public String implicitHeadersRegex; public boolean defaultDelegate; @Nullable + public String rawBodyMode; + @Nullable public String clientConfig = "test"; @Nullable public String clientConfigPrefix; @@ -53,6 +57,11 @@ public Options setDefaultDelegate(boolean defaultDelegate) { return this; } + public Options setRawBodyMode(@Nullable String rawBodyMode) { + this.rawBodyMode = rawBodyMode; + return this; + } + public Options setClientConfig(@Nullable String clientConfig) { this.clientConfig = clientConfig; return this; @@ -71,6 +80,7 @@ public String toString() { ", implicitHeaders=" + implicitHeaders + ", implicitHeadersRegex=" + implicitHeadersRegex + ", defaultDelegate=" + defaultDelegate + + ", rawBodyMode='" + rawBodyMode + '\'' + ", clientConfig='" + clientConfig + '\'' + ", clientConfigPrefix='" + clientConfigPrefix + '\'' + '}'; @@ -101,6 +111,7 @@ public static SwaggerParams[] generateParams() { "/example/petstoreV3_security_multi.yaml", "/example/petstoreV3_single_response.yaml", "/example/petstoreV3_same_response_model.yaml", + "/example/petstoreV3_bare_object.yaml", "/example/petstoreV3_responses.yaml", "/example/petstoreV3_types.yaml", "/example/petstoreV3_validation.yaml", @@ -176,6 +187,9 @@ protected final List generate(String name, String mode, String spec, Swagg if (options.clientConfigPrefix != null) { configurator.addAdditionalProperty("clientConfigPrefix", options.clientConfigPrefix); } + if (options.rawBodyMode != null) { + configurator.addAdditionalProperty("rawBodyMode", options.rawBodyMode); + } if (options.defaultDelegate) { configurator.addAdditionalProperty("delegateMethodBodyMode", "throwException"); @@ -192,6 +206,15 @@ protected final List generate(String name, String mode, String spec, Swagg var clientOptInput = configurator.toClientOptInput(); var generator = new DefaultGenerator(); - return generator.opts(clientOptInput).generate(); + var generatedFiles = new ArrayList<>(generator.opts(clientOptInput).generate()); + var generatedPaths = new HashSet<>(generatedFiles.stream().map(file -> file.toPath().toAbsolutePath()).toList()); + try (var files = Files.walk(openapiSourcesDir)) { + files.filter(Files::isRegularFile) + .map(Path::toAbsolutePath) + .filter(path -> !generatedPaths.contains(path)) + .map(Path::toFile) + .forEach(generatedFiles::add); + } + return generatedFiles; } } diff --git a/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/HttpClientJavaOpenapiTest.java b/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/HttpClientJavaOpenapiTest.java index 563c8b930..24aabcfdd 100644 --- a/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/HttpClientJavaOpenapiTest.java +++ b/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/HttpClientJavaOpenapiTest.java @@ -110,4 +110,272 @@ void sameResponseModelGetsSharedInterface() throws Exception { assertTrue(content.contains("return 400")); assertTrue(content.contains("return this.content().details()")); } + + @Test + void javadocsIncludeOpenapiModelAndOperationMetadata() throws Exception { + var files = generate( + "petstoreV2_javadocs", + "java-client", + getClass().getResource("/example/petstoreV2.yaml").toExternalForm(), + new SwaggerParams.Options() + ); + + var petContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.getFileName().toString().equals("Pet.java")) + .findFirst() + .orElseThrow()); + var apiContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.getFileName().toString().equals("PetApi.java")) + .findFirst() + .orElseThrow()); + + assertTrue(petContent.contains("* Pet - A pet for sale in the pet store")); + assertTrue(petContent.contains("* @param status pet status in the store")); + assertTrue(petContent.contains("* @param name name (example: doggie)")); + assertTrue(apiContent.contains("* POST /pet : Add a new pet to the store")); + assertTrue(apiContent.contains("* @param body Pet object that needs to be added to the store (required)")); + assertTrue(apiContent.contains("* @return Invalid input (status code 405)")); + } + + @Test + void enumMappersAreGeneratedAsModuleFactories() throws Exception { + var files = generate( + "petstoreV3_filter", + "java-client", + getClass().getResource("/example/petstoreV3_filter.yaml").toExternalForm(), + new SwaggerParams.Options() + ); + + var petDogContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.getFileName().toString().equals("PetDog.java")) + .findFirst() + .orElseThrow()); + + var moduleContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.getFileName().toString().equals("$PetDogBreedEnumMapperModule.java")) + .findFirst() + .orElseThrow()); + + assertFalse(petDogContent.contains("MapperModule")); + assertTrue(petDogContent.contains("DINGO_DON(\"Dingo-Don\")")); + assertTrue(petDogContent.contains("NUMBER_5(5)")); + assertFalse(petDogContent.contains("Constants.")); + assertFalse(petDogContent.contains("public static final class Constants")); + assertFalse(petDogContent.contains("public static final class JsonWriter")); + assertFalse(petDogContent.contains("public static final class JsonReader")); + assertFalse(petDogContent.contains("public static final class StringParameterConverter")); + assertTrue(petDogContent.contains("* Dingo breed")); + assertTrue(petDogContent.contains("* enum with int value")); + + assertTrue(moduleContent.contains("public interface $PetDogBreedEnumMapperModule")); + assertTrue(moduleContent.contains("@DefaultComponent")); + assertTrue(moduleContent.contains("default JsonWriter breedEnumJsonWriter()")); + assertTrue(moduleContent.contains("default JsonReader breedEnumJsonReader()")); + assertTrue(moduleContent.contains("default HttpClientParameterWriter breedEnumStringParameterConverter()")); + assertTrue(moduleContent.contains("new EnumJsonWriter<>(PetDog.BreedEnum.values(), PetDog.BreedEnum::getValue, (gen, object) ->")); + assertTrue(moduleContent.contains("new EnumJsonReader<>(PetDog.BreedEnum.values(), PetDog.BreedEnum::getValue, parser -> switch (parser.currentToken())")); + } + + @Test + void enumMappersUseJsonDelegateForNonInlineValueTypes() throws Exception { + var files = generate( + "petstoreV3_enum", + "java-client", + getClass().getResource("/example/petstoreV3_enum.yaml").toExternalForm(), + new SwaggerParams.Options() + ); + + var moduleContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.getFileName().toString().equals("$PetNonReqDoubleEnumMapperModule.java")) + .findFirst() + .orElseThrow()); + + assertTrue(moduleContent.contains("default JsonWriter nonReqDoubleEnumJsonWriter(JsonWriter delegate)")); + assertTrue(moduleContent.contains("return new EnumJsonWriter<>(Pet.NonReqDoubleEnum.values(), Pet.NonReqDoubleEnum::getValue, delegate)")); + assertTrue(moduleContent.contains("default JsonReader nonReqDoubleEnumJsonReader(JsonReader delegate)")); + assertTrue(moduleContent.contains("return new EnumJsonReader<>(Pet.NonReqDoubleEnum.values(), Pet.NonReqDoubleEnum::getValue, delegate)")); + assertFalse(moduleContent.contains("parser -> switch")); + } + + @Test + void recordsGetWithBuilderMethods() throws Exception { + var files = generate( + "petstoreV3_enum", + "java-client", + getClass().getResource("/example/petstoreV3_enum.yaml").toExternalForm(), + new SwaggerParams.Options() + ); + + var content = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.getFileName().toString().equals("Pet.java")) + .findFirst() + .orElseThrow()); + + assertTrue(content.contains("public Pet withId(long id)")); + assertTrue(content.contains("if (this.id == id) return this; return new Pet(id, this.nullableType")); + assertTrue(content.contains("public Pet withNonReqDouble(@Nullable NonReqDoubleEnum nonReqDouble)")); + assertTrue(content.contains("if (Objects.equals(this.nonReqDouble, nonReqDouble)) return this; return new Pet(this.id, this.nullableType")); + assertFalse(content.contains("* (nonReqDouble)")); + + var filesWithDefaults = generate( + "petstoreV3_types", + "java-client", + getClass().getResource("/example/petstoreV3_types.yaml").toExternalForm(), + new SwaggerParams.Options() + ); + + var contentWithDefaults = Files.readString(filesWithDefaults.stream() + .map(java.io.File::toPath) + .filter(path -> path.getFileName().toString().equals("Pet.java")) + .findFirst() + .orElseThrow()); + + assertTrue(contentWithDefaults.contains("* (default: 1)")); + } + + @Test + void optionalArgsAreGeneratedAsMutableClasses() throws Exception { + var files = generate( + "petstoreV3_request_parameters", + "java-client", + getClass().getResource("/example/petstoreV3_request_parameters.yaml").toExternalForm(), + new SwaggerParams.Options() + ); + + var apiContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.getFileName().toString().equals("PetsApi.java")) + .findFirst() + .orElseThrow()); + var optionalArgsContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.getFileName().toString().equals("PetsApiListPetsOptArgs.java")) + .findFirst() + .orElseThrow()); + + assertTrue(apiContent.contains("PetsApiListPetsOptArgs optionalArguments")); + assertFalse(apiContent.contains("final class ListPetsOptArgs")); + assertTrue(optionalArgsContent.contains("public final class PetsApiListPetsOptArgs")); + assertFalse(optionalArgsContent.contains("record PetsApiListPetsOptArgs")); + assertTrue(optionalArgsContent.contains("public static PetsApiListPetsOptArgs empty()")); + assertTrue(optionalArgsContent.contains("public static PetsApiListPetsOptArgs defaults()")); + assertTrue(optionalArgsContent.contains("private PetsApiListPetsOptArgs(")); + assertTrue(optionalArgsContent.contains("private @Nullable Integer intOptional;")); + assertTrue(optionalArgsContent.contains("public @Nullable Integer intOptional()")); + assertTrue(optionalArgsContent.contains("this.intOptional = intOptional;")); + assertTrue(optionalArgsContent.contains("public PetsApiListPetsOptArgs withIntOptional(Integer intOptional)")); + assertTrue(optionalArgsContent.contains("return this;")); + } + + @Test + void bareObjectPropertiesAreGeneratedAsObject() throws Exception { + var files = generate( + "petstoreV3_bare_object_bytes_default", + "java-client", + getClass().getResource("/example/petstoreV3_bare_object.yaml").toExternalForm(), + new SwaggerParams.Options() + ); + + var content = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.getFileName().toString().equals("Pet.java")) + .findFirst() + .orElseThrow()); + + assertTrue(content.contains("public record Pet(long id, Object metadata, @Nullable Object optionalMetadata)")); + assertTrue(content.contains("public Pet withMetadata(Object metadata)")); + assertTrue(content.contains("public Pet withOptionalMetadata(@Nullable Object optionalMetadata)")); + } + + @Test + void bareObjectRequestAndResponseAreGeneratedAsRawHttpBodyTypes() throws Exception { + var files = generate( + "petstoreV3_bare_object_raw", + "java-client", + getClass().getResource("/example/petstoreV3_bare_object.yaml").toExternalForm(), + new SwaggerParams.Options().setRawBodyMode("RAW") + ); + + var apiContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_raw")) + .filter(path -> path.getFileName().toString().equals("DefaultApi.java")) + .findFirst() + .orElseThrow()); + var responsesContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_raw")) + .filter(path -> path.getFileName().toString().equals("DefaultApiResponses.java")) + .findFirst() + .orElseThrow()); + var responseMapperContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_raw")) + .filter(path -> path.getFileName().toString().equals("DefaultApiClientResponseMappers.java")) + .findFirst() + .orElseThrow()); + + assertTrue(apiContent.contains("StoreInventoryApiResponse storeInventory(HttpBodyOutput body)")); + assertTrue(apiContent.contains("RawObjectApiResponse rawObject(HttpBodyOutput body)")); + assertTrue(responsesContent.contains("sealed interface StoreInventoryApiResponse")); + assertTrue(responsesContent.contains("record StoreInventory200ApiResponse(")); + assertTrue(responsesContent.contains("HttpBodyInput content) implements StoreInventoryObjectApiResponse")); + assertTrue(responsesContent.contains("StoreInventory400ApiResponse")); + assertTrue(responsesContent.contains("ErrorMessage")); + assertTrue(responsesContent.contains("implements StoreInventory")); + assertTrue(responsesContent.contains("record StoreInventory500ApiResponse(")); + assertTrue(responsesContent.contains("HttpBodyInput content) implements StoreInventoryObjectApiResponse")); + assertTrue(responsesContent.contains("sealed interface RawObjectApiResponse")); + assertTrue(responsesContent.contains("record RawObject200ApiResponse(")); + assertTrue(responsesContent.contains("HttpBodyInput content) implements RawObjectObjectApiResponse")); + assertTrue(responsesContent.contains("record RawObject400ApiResponse(")); + assertTrue(responsesContent.contains("record RawObject500ApiResponse(")); + assertTrue(responseMapperContent.contains("private final HttpClientResponseMapper delegate")); + assertTrue(responseMapperContent.contains("private final HttpClientResponseMapper delegate")); + assertFalse(responseMapperContent.contains("@Json HttpClientResponseMapper")); + } + + @Test + void bareObjectRequestAndResponseUseByteArrayByDefault() throws Exception { + var files = generate( + "petstoreV3_bare_object_bytes_default", + "java-client", + getClass().getResource("/example/petstoreV3_bare_object.yaml").toExternalForm(), + new SwaggerParams.Options() + ); + + var apiContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_bytes_default")) + .filter(path -> path.getFileName().toString().equals("DefaultApi.java")) + .findFirst() + .orElseThrow()); + var responsesContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_bytes_default")) + .filter(path -> path.getFileName().toString().equals("DefaultApiResponses.java")) + .findFirst() + .orElseThrow()); + var responseMapperContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_bytes_default")) + .filter(path -> path.getFileName().toString().equals("DefaultApiClientResponseMappers.java")) + .findFirst() + .orElseThrow()); + + assertTrue(apiContent.contains("StoreInventoryApiResponse storeInventory(byte[] body)")); + assertTrue(apiContent.contains("RawObjectApiResponse rawObject(byte[] body)")); + assertTrue(responsesContent.contains("record StoreInventory200ApiResponse(byte[] content)")); + assertTrue(responsesContent.contains("record StoreInventory500ApiResponse(byte[] content)")); + assertTrue(responsesContent.contains("record RawObject200ApiResponse(byte[] content)")); + assertTrue(responsesContent.contains("record RawObject400ApiResponse(byte[] content)")); + assertTrue(responsesContent.contains("record RawObject500ApiResponse(byte[] content)")); + assertTrue(responseMapperContent.contains("private final HttpClientResponseMapper delegate")); + } } diff --git a/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/HttpClientKotlinOpenapiTest.java b/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/HttpClientKotlinOpenapiTest.java index 4cb0605f0..7c3db6d09 100644 --- a/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/HttpClientKotlinOpenapiTest.java +++ b/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/HttpClientKotlinOpenapiTest.java @@ -111,4 +111,116 @@ void sameResponseModelGetsSharedInterface() throws Exception { assertTrue(content.contains("get() = 400")); assertTrue(content.contains("get() = content.details")); } + + @Test + void enumValueTypesSupportDouble() throws Exception { + var files = generate( + "petstoreV3_enum", + "kotlin-client", + getClass().getResource("/example/petstoreV3_enum.yaml").toExternalForm(), + new SwaggerParams.Options() + ); + + var content = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.getFileName().toString().equals("Pet.kt")) + .findFirst() + .orElseThrow()); + + assertTrue(content.contains("public enum class NonReqDoubleEnum private constructor(")); + assertTrue(content.contains("public val `value`: Double")); + assertTrue(content.contains("`delegate`: io.koraframework.json.common.JsonWriter")); + assertTrue(content.contains("`delegate`: io.koraframework.json.common.JsonReader")); + } + + @Test + void bareObjectRequestAndResponseAreGeneratedAsRawHttpBodyTypes() throws Exception { + var files = generate( + "petstoreV3_bare_object_raw", + "kotlin-client", + getClass().getResource("/example/petstoreV3_bare_object.yaml").toExternalForm(), + new SwaggerParams.Options().setRawBodyMode("RAW") + ); + + var apiContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_raw")) + .filter(path -> path.getFileName().toString().equals("DefaultApi.kt")) + .findFirst() + .orElseThrow()); + var responsesContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_raw")) + .filter(path -> path.getFileName().toString().equals("DefaultApiResponses.kt")) + .findFirst() + .orElseThrow()); + var modelContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_raw")) + .filter(path -> path.getFileName().toString().equals("Pet.kt")) + .findFirst() + .orElseThrow()); + var errorContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_raw")) + .filter(path -> path.getFileName().toString().equals("ErrorMessage.kt")) + .findFirst() + .orElseThrow()); + + assertTrue(apiContent.contains("public fun storeInventory(body: HttpBodyOutput): DefaultApiResponses.StoreInventoryApiResponse")); + assertTrue(apiContent.contains("public fun rawObject(body: HttpBodyOutput): DefaultApiResponses.RawObjectApiResponse")); + assertTrue(responsesContent.contains("public sealed interface StoreInventoryApiResponse")); + assertTrue(responsesContent.contains("public data class StoreInventory200ApiResponse(")); + assertTrue(responsesContent.contains("public val content: HttpBodyInput")); + assertTrue(responsesContent.contains("public data class StoreInventory400ApiResponse(")); + assertTrue(responsesContent.contains("public val content: ErrorMessage")); + assertTrue(responsesContent.contains("public data class StoreInventory500ApiResponse(")); + assertTrue(responsesContent.contains("public val content: HttpBodyInput")); + assertTrue(responsesContent.contains("public sealed interface RawObjectApiResponse")); + assertTrue(responsesContent.contains("public data class RawObject200ApiResponse(")); + assertTrue(responsesContent.contains("public data class RawObject400ApiResponse(")); + assertTrue(responsesContent.contains("public data class RawObject500ApiResponse(")); + assertTrue(modelContent.contains("public data class Pet(")); + assertTrue(modelContent.contains("public val metadata: Any")); + assertTrue(modelContent.contains("public val optionalMetadata: Any? = null")); + assertTrue(errorContent.contains("public data class ErrorMessage(")); + assertTrue(errorContent.contains("public val message: String")); + } + + @Test + void bareObjectRequestAndResponseUseByteArrayByDefault() throws Exception { + var files = generate( + "petstoreV3_bare_object_bytes_default", + "kotlin-client", + getClass().getResource("/example/petstoreV3_bare_object.yaml").toExternalForm(), + new SwaggerParams.Options() + ); + + var apiContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_bytes_default")) + .filter(path -> path.getFileName().toString().equals("DefaultApi.kt")) + .findFirst() + .orElseThrow()); + var responsesContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_bytes_default")) + .filter(path -> path.getFileName().toString().equals("DefaultApiResponses.kt")) + .findFirst() + .orElseThrow()); + var responseMapperContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_bytes_default")) + .filter(path -> path.getFileName().toString().equals("DefaultApiClientResponseMappers.kt")) + .findFirst() + .orElseThrow()); + + assertTrue(apiContent.contains("public fun storeInventory(body: ByteArray): DefaultApiResponses.StoreInventoryApiResponse")); + assertTrue(apiContent.contains("public fun rawObject(body: ByteArray): DefaultApiResponses.RawObjectApiResponse")); + assertTrue(responsesContent.contains("public val content: ByteArray")); + assertTrue(responsesContent.contains("public data class RawObject200ApiResponse(")); + assertTrue(responsesContent.contains("public data class RawObject400ApiResponse(")); + assertTrue(responsesContent.contains("public data class RawObject500ApiResponse(")); + assertTrue(responseMapperContent.contains("HttpClientResponseMapper")); + } } diff --git a/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/HttpServerJavaOpenapiTest.java b/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/HttpServerJavaOpenapiTest.java index 7e701ec5a..49907e99e 100644 --- a/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/HttpServerJavaOpenapiTest.java +++ b/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/HttpServerJavaOpenapiTest.java @@ -1,8 +1,13 @@ package io.koraframework.openapi.generator; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertTrue; + public class HttpServerJavaOpenapiTest extends BaseJavaOpenapiTest { @ParameterizedTest @MethodSource("generateParams") @@ -14,4 +19,120 @@ void test(SwaggerParams params) throws Exception { params.options() ); } + + @Test + void javadocsIncludeOpenapiOperationMetadata() throws Exception { + var files = generate( + "petstoreV2_javadocs", + "java-server", + getClass().getResource("/example/petstoreV2.yaml").toExternalForm(), + new SwaggerParams.Options() + ); + + var apiContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.getFileName().toString().equals("PetApiController.java")) + .findFirst() + .orElseThrow()); + var delegateContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.getFileName().toString().equals("PetApiDelegate.java")) + .findFirst() + .orElseThrow()); + + assertTrue(apiContent.contains("* POST /pet : Add a new pet to the store")); + assertTrue(apiContent.contains("* @param body Pet object that needs to be added to the store (required)")); + assertTrue(apiContent.contains("* @return Invalid input (status code 405)")); + assertTrue(delegateContent.contains("* POST /pet : Add a new pet to the store")); + assertTrue(delegateContent.contains("* @param body Pet object that needs to be added to the store (required)")); + assertTrue(delegateContent.contains("* @return Invalid input (status code 405)")); + } + + @Test + void bareObjectRequestAndResponseAreGeneratedAsRawHttpBodyTypes() throws Exception { + var files = generate( + "petstoreV3_bare_object_raw", + "java-server", + getClass().getResource("/example/petstoreV3_bare_object.yaml").toExternalForm(), + new SwaggerParams.Options().setRawBodyMode("RAW") + ); + + var controllerContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_raw")) + .filter(path -> path.getFileName().toString().equals("DefaultApiController.java")) + .findFirst() + .orElseThrow()); + var delegateContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_raw")) + .filter(path -> path.getFileName().toString().equals("DefaultApiDelegate.java")) + .findFirst() + .orElseThrow()); + var responsesContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_raw")) + .filter(path -> path.getFileName().toString().equals("DefaultApiResponses.java")) + .findFirst() + .orElseThrow()); + var responseMapperContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_raw")) + .filter(path -> path.getFileName().toString().equals("DefaultApiServerResponseMappers.java")) + .findFirst() + .orElseThrow()); + + assertTrue(controllerContent.contains("StoreInventoryApiResponse storeInventory(HttpBodyInput body)")); + assertTrue(delegateContent.contains("StoreInventoryApiResponse storeInventory(HttpBodyInput body)")); + assertTrue(controllerContent.contains("RawObjectApiResponse rawObject(HttpBodyInput body)")); + assertTrue(delegateContent.contains("RawObjectApiResponse rawObject(HttpBodyInput body)")); + assertTrue(responsesContent.contains("record StoreInventory200ApiResponse(")); + assertTrue(responsesContent.contains("HttpBodyOutput content) implements StoreInventoryApiResponse")); + assertTrue(responsesContent.contains("record StoreInventory400ApiResponse(ErrorMessage content) implements StoreInventoryApiResponse")); + assertTrue(responsesContent.contains("record StoreInventory500ApiResponse(")); + assertTrue(responsesContent.contains("record RawObject200ApiResponse(")); + assertTrue(responsesContent.contains("HttpBodyOutput content) implements RawObjectApiResponse")); + assertTrue(responsesContent.contains("record RawObject400ApiResponse(")); + assertTrue(responsesContent.contains("record RawObject500ApiResponse(")); + assertTrue(responseMapperContent.contains("HttpServerResponseMapper> response200Delegate")); + assertTrue(responseMapperContent.contains("HttpServerResponseMapper> response400Delegate")); + } + + @Test + void bareObjectRequestAndResponseUseByteArrayByDefault() throws Exception { + var files = generate( + "petstoreV3_bare_object_bytes_default", + "java-server", + getClass().getResource("/example/petstoreV3_bare_object.yaml").toExternalForm(), + new SwaggerParams.Options() + ); + + var controllerContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_bytes_default")) + .filter(path -> path.getFileName().toString().equals("DefaultApiController.java")) + .findFirst() + .orElseThrow()); + var responsesContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_bytes_default")) + .filter(path -> path.getFileName().toString().equals("DefaultApiResponses.java")) + .findFirst() + .orElseThrow()); + var responseMapperContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_bytes_default")) + .filter(path -> path.getFileName().toString().equals("DefaultApiServerResponseMappers.java")) + .findFirst() + .orElseThrow()); + + assertTrue(controllerContent.contains("StoreInventoryApiResponse storeInventory(byte[] body)")); + assertTrue(controllerContent.contains("RawObjectApiResponse rawObject(byte[] body)")); + assertTrue(responsesContent.contains("record StoreInventory200ApiResponse(byte[] content) implements StoreInventoryApiResponse")); + assertTrue(responsesContent.contains("record StoreInventory500ApiResponse(byte[] content) implements StoreInventoryApiResponse")); + assertTrue(responsesContent.contains("record RawObject200ApiResponse(byte[] content) implements RawObjectApiResponse")); + assertTrue(responsesContent.contains("record RawObject400ApiResponse(byte[] content) implements RawObjectApiResponse")); + assertTrue(responsesContent.contains("record RawObject500ApiResponse(byte[] content) implements RawObjectApiResponse")); + assertTrue(responseMapperContent.contains("HttpServerResponseMapper> response200Delegate")); + } } diff --git a/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/HttpServerKotlinOpenapiTest.java b/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/HttpServerKotlinOpenapiTest.java index 28c2caeab..e791c9ff4 100644 --- a/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/HttpServerKotlinOpenapiTest.java +++ b/openapi/openapi-generator/src/test/java/io/koraframework/openapi/generator/HttpServerKotlinOpenapiTest.java @@ -1,8 +1,13 @@ package io.koraframework.openapi.generator; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertTrue; + public class HttpServerKotlinOpenapiTest extends BaseKotlinOpenapiTest { @ParameterizedTest @MethodSource("generateParams") @@ -14,4 +19,88 @@ void test(SwaggerParams params) throws Exception { params.options() ); } + + @Test + void bareObjectRequestAndResponseAreGeneratedAsRawHttpBodyTypes() throws Exception { + var files = generate( + "petstoreV3_bare_object_raw", + "kotlin-server", + getClass().getResource("/example/petstoreV3_bare_object.yaml").toExternalForm(), + new SwaggerParams.Options().setRawBodyMode("RAW") + ); + + var controllerContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_raw")) + .filter(path -> path.getFileName().toString().equals("DefaultApiController.kt")) + .findFirst() + .orElseThrow()); + var delegateContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_raw")) + .filter(path -> path.getFileName().toString().equals("DefaultApiDelegate.kt")) + .findFirst() + .orElseThrow()); + var responsesContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_raw")) + .filter(path -> path.getFileName().toString().equals("DefaultApiResponses.kt")) + .findFirst() + .orElseThrow()); + var responseMapperContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_raw")) + .filter(path -> path.getFileName().toString().equals("DefaultApiServerResponseMappers.kt")) + .findFirst() + .orElseThrow()); + + assertTrue(controllerContent.contains("public fun storeInventory(body: HttpBodyInput): DefaultApiResponses.StoreInventoryApiResponse")); + assertTrue(delegateContent.contains("public fun storeInventory(body: HttpBodyInput): DefaultApiResponses.StoreInventoryApiResponse")); + assertTrue(controllerContent.contains("public fun rawObject(body: HttpBodyInput): DefaultApiResponses.RawObjectApiResponse")); + assertTrue(delegateContent.contains("public fun rawObject(body: HttpBodyInput): DefaultApiResponses.RawObjectApiResponse")); + assertTrue(responsesContent.contains("public val content: HttpBodyOutput")); + assertTrue(responsesContent.contains("public data class RawObject200ApiResponse(")); + assertTrue(responsesContent.contains("public data class RawObject400ApiResponse(")); + assertTrue(responsesContent.contains("public data class RawObject500ApiResponse(")); + assertTrue(responsesContent.contains("public val content: ErrorMessage")); + assertTrue(responseMapperContent.contains("HttpServerResponseMapper>")); + assertTrue(responseMapperContent.contains("HttpServerResponseMapper>")); + } + + @Test + void bareObjectRequestAndResponseUseByteArrayByDefault() throws Exception { + var files = generate( + "petstoreV3_bare_object_bytes_default", + "kotlin-server", + getClass().getResource("/example/petstoreV3_bare_object.yaml").toExternalForm(), + new SwaggerParams.Options() + ); + + var controllerContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_bytes_default")) + .filter(path -> path.getFileName().toString().equals("DefaultApiController.kt")) + .findFirst() + .orElseThrow()); + var responsesContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_bytes_default")) + .filter(path -> path.getFileName().toString().equals("DefaultApiResponses.kt")) + .findFirst() + .orElseThrow()); + var responseMapperContent = Files.readString(files.stream() + .map(java.io.File::toPath) + .filter(path -> path.toString().contains("petstoreV3_bare_object_bytes_default")) + .filter(path -> path.getFileName().toString().equals("DefaultApiServerResponseMappers.kt")) + .findFirst() + .orElseThrow()); + + assertTrue(controllerContent.contains("public fun storeInventory(body: ByteArray): DefaultApiResponses.StoreInventoryApiResponse")); + assertTrue(controllerContent.contains("public fun rawObject(body: ByteArray): DefaultApiResponses.RawObjectApiResponse")); + assertTrue(responsesContent.contains("public val content: ByteArray")); + assertTrue(responsesContent.contains("public data class RawObject200ApiResponse(")); + assertTrue(responsesContent.contains("public data class RawObject400ApiResponse(")); + assertTrue(responsesContent.contains("public data class RawObject500ApiResponse(")); + assertTrue(responseMapperContent.contains("HttpServerResponseMapper>")); + } } diff --git a/openapi/openapi-generator/src/test/resources/example/petstoreV3_bare_object.yaml b/openapi/openapi-generator/src/test/resources/example/petstoreV3_bare_object.yaml new file mode 100644 index 000000000..a6ab55b96 --- /dev/null +++ b/openapi/openapi-generator/src/test/resources/example/petstoreV3_bare_object.yaml @@ -0,0 +1,104 @@ +openapi: 3.0.3 + +info: + title: PetStore with bare object + version: 1.0.0 + +paths: + /store/inventory: + get: + summary: Returns pet inventories by status + operationId: getInventory + responses: + '200': + description: A page of pet + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + post: + summary: Stores any inventory payload + operationId: storeInventory + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Stored payload + content: + application/json: + schema: + type: object + '400': + description: Validation error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorMessage" + '404': + description: Validation error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorMessage" + '500': + description: Unknown server error + content: + application/json: + schema: + type: object + /store/raw-object: + post: + summary: Exchanges any raw object payload + operationId: rawObject + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Raw object response + content: + application/json: + schema: + type: object + '400': + description: Raw object validation error + content: + application/json: + schema: + type: object + '500': + description: Raw object server error + content: + application/json: + schema: + type: object + +components: + schemas: + ErrorMessage: + type: object + required: + - message + properties: + message: + type: string + Pet: + type: object + required: + - id + - metadata + properties: + id: + type: integer + format: int64 + metadata: + type: object + optionalMetadata: + type: object diff --git a/openapi/openapi-generator/src/test/resources/example/petstoreV3_enum.yaml b/openapi/openapi-generator/src/test/resources/example/petstoreV3_enum.yaml index c40c49595..c0e255bb5 100644 --- a/openapi/openapi-generator/src/test/resources/example/petstoreV3_enum.yaml +++ b/openapi/openapi-generator/src/test/resources/example/petstoreV3_enum.yaml @@ -104,6 +104,10 @@ components: nonReqInt: type: integer enum: [ 5, 7, 8, 10 ] + nonReqDouble: + type: number + format: double + enum: [ 1.5, 2.5 ] nonReqArrayInt: type: array items: diff --git a/openapi/openapi-generator/src/test/resources/example/petstoreV3_filter.yaml b/openapi/openapi-generator/src/test/resources/example/petstoreV3_filter.yaml index b67d39459..9cf61b0b9 100644 --- a/openapi/openapi-generator/src/test/resources/example/petstoreV3_filter.yaml +++ b/openapi/openapi-generator/src/test/resources/example/petstoreV3_filter.yaml @@ -351,6 +351,11 @@ components: breed: type: string enum: [ Dingo-Don, Husky, Retriever, Shepherd ] + x-enum-descriptions: + - Dingo breed + - Husky breed + - Retriever breed + - Shepherd breed int-breed: description: "enum with int value" type: int @@ -550,4 +555,3 @@ security: - oAuth: - pets:write - pets:read -