From d935d7326c7f7fd8184a85727aff42abf73d9298 Mon Sep 17 00:00:00 2001 From: altro3 Date: Thu, 25 Jan 2024 18:03:10 +0700 Subject: [PATCH] Fix lost enum classes when it unnamed schemas for endpoint parameters (#1382) Co-authored-by: Sergio del Amo --- gradle.properties | 3 - gradle/libs.versions.toml | 2 + .../AbstractMicronautJavaCodegen.java | 45 +- .../AbstractMicronautKotlinCodegen.java | 38 ++ .../MicronautInlineModelResolver.java | 553 ++++++++++++++++++ .../common/operationAnnotations.mustache | 2 +- .../common/operationAnnotations.mustache | 2 +- .../JavaMicronautServerCodegenTest.java | 13 + .../KotlinMicronautClientCodegenTest.java | 6 +- .../KotlinMicronautServerCodegenTest.java | 21 +- .../test/resources/3_0/controller-enum.yml | 113 ++++ .../test/api/RequestBodyControllerSpec.groovy | 3 +- .../test/api/RequestBodyControllerTest.kt | 2 +- .../test/api/RequestBodyControllerTest.kt | 2 +- 14 files changed, 784 insertions(+), 21 deletions(-) create mode 100644 openapi-generator/src/main/java/io/micronaut/openapi/generator/MicronautInlineModelResolver.java create mode 100644 openapi-generator/src/test/resources/3_0/controller-enum.yml diff --git a/gradle.properties b/gradle.properties index e925db04c0..7c29508adf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,5 @@ projectVersion=6.5.0-SNAPSHOT projectGroup=io.micronaut.openapi -micronautDocsVersion=2.0.0 -groovyVersion=4.0.15 -spockVersion=2.3-groovy-4.0 title=OpenAPI/Swagger Support projectDesc=Configuration to integrate Micronaut and OpenAPI/Swagger diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1229316de..fe9f983435 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ managed-freemarker = "2.3.32" managed-pegdown = "1.6.0" managed-evo-inflector = "1.3" +groovy = "4.0.17" kotlin = "1.9.22" ksp = "1.9.22-1.0.17" jspecify = "0.3.0" @@ -20,6 +21,7 @@ swagger-parser-v3 = "2.1.20" javaparser = "3.25.8" commons-codec = "1.16.0" +micronaut-docs = "2.0.0" micronaut = "4.3.1" micronaut-platform = "4.2.4" micronaut-security = "4.5.0" diff --git a/openapi-generator/src/main/java/io/micronaut/openapi/generator/AbstractMicronautJavaCodegen.java b/openapi-generator/src/main/java/io/micronaut/openapi/generator/AbstractMicronautJavaCodegen.java index a0318fb5b5..2dbebf22ab 100644 --- a/openapi-generator/src/main/java/io/micronaut/openapi/generator/AbstractMicronautJavaCodegen.java +++ b/openapi-generator/src/main/java/io/micronaut/openapi/generator/AbstractMicronautJavaCodegen.java @@ -25,14 +25,15 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Random; import java.util.Set; import java.util.stream.Collectors; import io.micronaut.openapi.generator.Formatting.ReplaceDotsWithUnderscoreLambda; +import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.media.Schema; -import io.swagger.v3.oas.models.parameters.Parameter; import io.swagger.v3.oas.models.servers.Server; import org.apache.commons.lang3.StringUtils; @@ -147,6 +148,8 @@ protected AbstractMicronautJavaCodegen() { appName = artifactId; generateSwaggerAnnotations = this instanceof JavaMicronautClientCodegen ? OPT_GENERATE_SWAGGER_ANNOTATIONS_FALSE : OPT_GENERATE_SWAGGER_ANNOTATIONS_SWAGGER_2; generateOperationOnlyForFirstTag = this instanceof JavaMicronautServerCodegen; + openApiNullable = false; + inlineSchemaOption.put("RESOLVE_INLINE_ENUMS", "true"); // CHECKSTYLE:ON // Set implemented features for user information @@ -589,6 +592,41 @@ public void addOperationToGroup(String tag, String resourcePath, Operation opera super.addOperationToGroup(super.sanitizeTag(tag), resourcePath, operation, co, operations); } + @Override + public void preprocessOpenAPI(OpenAPI openAPI) { + + if (openAPI.getPaths() != null) { + for (var path : openAPI.getPaths().values()) { + if (path.getParameters() == null || path.getParameters().isEmpty()) { + continue; + } + + for (var op : path.readOperations()) { + if (op.getParameters() == null) { + op.setParameters(new ArrayList<>()); + } + for (var param : path.getParameters()) { + var found = false; + for (var opParam : op.getParameters()) { + if (Objects.equals(opParam.getName(), param.getName())) { + found = true; + break; + } + } + if (!found) { + op.getParameters().add(param); + } + } + } + } + } + + var inlineModelResolver = new MicronautInlineModelResolver(openAPI); + inlineModelResolver.flattenPaths(); + + super.preprocessOpenAPI(openAPI); + } + @Override public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { objs = super.postProcessOperationsWithModels(objs, allModels); @@ -699,11 +737,6 @@ public CodegenModel fromModel(String name, Schema model) { return codegenModel; } - @Override - public CodegenParameter fromParameter(Parameter param, Set imports) { - return super.fromParameter(param, imports); - } - @Override public CodegenOperation fromOperation(String path, String httpMethod, Operation operation, List servers) { CodegenOperation op = super.fromOperation(path, httpMethod, operation, servers); diff --git a/openapi-generator/src/main/java/io/micronaut/openapi/generator/AbstractMicronautKotlinCodegen.java b/openapi-generator/src/main/java/io/micronaut/openapi/generator/AbstractMicronautKotlinCodegen.java index e81d0eacde..73adbfe820 100644 --- a/openapi-generator/src/main/java/io/micronaut/openapi/generator/AbstractMicronautKotlinCodegen.java +++ b/openapi-generator/src/main/java/io/micronaut/openapi/generator/AbstractMicronautKotlinCodegen.java @@ -24,12 +24,14 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Random; import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; import io.micronaut.openapi.generator.Formatting.ReplaceDotsWithUnderscoreLambda; +import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.examples.Example; import io.swagger.v3.oas.models.media.ArraySchema; @@ -197,6 +199,7 @@ protected AbstractMicronautKotlinCodegen() { generateSwaggerAnnotations = this instanceof KotlinMicronautClientCodegen ? OPT_GENERATE_SWAGGER_ANNOTATIONS_FALSE : OPT_GENERATE_SWAGGER_ANNOTATIONS_SWAGGER_2; generateOperationOnlyForFirstTag = this instanceof KotlinMicronautServerCodegen; enumPropertyNaming = CodegenConstants.ENUM_PROPERTY_NAMING_TYPE.UPPERCASE; + inlineSchemaOption.put("RESOLVE_INLINE_ENUMS", "true"); // CHECKSTYLE:ON // Set implemented features for user information @@ -636,6 +639,41 @@ public void addOperationToGroup(String tag, String resourcePath, Operation opera super.addOperationToGroup(super.sanitizeTag(tag), resourcePath, operation, co, operations); } + @Override + public void preprocessOpenAPI(OpenAPI openAPI) { + + if (openAPI.getPaths() != null) { + for (var path : openAPI.getPaths().values()) { + if (path.getParameters() == null || path.getParameters().isEmpty()) { + continue; + } + + for (var op : path.readOperations()) { + if (op.getParameters() == null) { + op.setParameters(new ArrayList<>()); + } + for (var param : path.getParameters()) { + var found = false; + for (var opParam : op.getParameters()) { + if (Objects.equals(opParam.getName(), param.getName())) { + found = true; + break; + } + } + if (!found) { + op.getParameters().add(param); + } + } + } + } + } + + var inlineModelResolver = new MicronautInlineModelResolver(openAPI); + inlineModelResolver.flattenPaths(); + + super.preprocessOpenAPI(openAPI); + } + @Override public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { objs = super.postProcessOperationsWithModels(objs, allModels); diff --git a/openapi-generator/src/main/java/io/micronaut/openapi/generator/MicronautInlineModelResolver.java b/openapi-generator/src/main/java/io/micronaut/openapi/generator/MicronautInlineModelResolver.java new file mode 100644 index 0000000000..7ce68bb066 --- /dev/null +++ b/openapi-generator/src/main/java/io/micronaut/openapi/generator/MicronautInlineModelResolver.java @@ -0,0 +1,553 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.openapi.generator; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.swagger.v3.core.util.Json; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.callbacks.Callback; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.ComposedSchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; + +import org.apache.commons.lang3.StringUtils; +import org.openapitools.codegen.utils.ModelUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Inline model resolver. + * + * @since 6.5.0 + */ +public final class MicronautInlineModelResolver { + + private static final Logger LOGGER = LoggerFactory.getLogger(MicronautInlineModelResolver.class); + + // structure mapper sorts properties alphabetically on write to ensure models are + // serialized consistently for lookup of existing models + private static ObjectMapper structureMapper; + + static { + structureMapper = Json.mapper().copy(); + structureMapper.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true); + structureMapper.writer(new DefaultPrettyPrinter()); + } + + private OpenAPI openAPI; + private Map addedModels = new HashMap<>(); + private Map generatedSignature = new HashMap<>(); + private Map inlineSchemaNameMapping = new HashMap<>(); + private Map inlineSchemaOptions = new HashMap<>(); + private Set inlineSchemaNameMappingValues = new HashSet<>(); + private boolean resolveInlineEnums = true; + private Boolean refactorAllOfInlineSchemas; // refactor allOf inline schemas into $ref + + // a set to keep track of names generated for inline schemas + private Set uniqueNames = new HashSet<>(); + + public MicronautInlineModelResolver(OpenAPI openAPI) { + this.openAPI = openAPI; + } + + public void flattenPaths() { + Paths paths = openAPI.getPaths(); + if (paths == null) { + return; + } + + for (Map.Entry pathsEntry : paths.entrySet()) { + PathItem path = pathsEntry.getValue(); + Map operationsMap = new LinkedHashMap<>(path.readOperationsMap()); + + // use path name (e.g. /foo/bar) and HTTP verb to come up with a name + // in case operationId is not defined later in other methods + String pathname = pathsEntry.getKey(); + + // Include callback operation as well + for (Map.Entry operationEntry : new LinkedHashMap<>(path.readOperationsMap()).entrySet()) { + Operation operation = operationEntry.getValue(); + Map callbacks = operation.getCallbacks(); + if (callbacks != null) { + for (Map.Entry callbackEntry : callbacks.entrySet()) { + Callback callback = callbackEntry.getValue(); + for (Map.Entry pathItemEntry : callback.entrySet()) { + PathItem pathItem = pathItemEntry.getValue(); + operationsMap.putAll(pathItem.readOperationsMap()); + } + } + } + } + + for (Map.Entry operationEntry : operationsMap.entrySet()) { + Operation operation = operationEntry.getValue(); + String inlineSchemaName = getInlineSchemaName(operationEntry.getKey(), pathname); + flattenPathItemParameters(inlineSchemaName, operation, path); + } + } + } + + private void flattenPathItemParameters(String modelName, Operation operation, PathItem pathItem) { + List parameters = new ArrayList<>(); + if (pathItem.getParameters() != null) { + parameters.addAll(pathItem.getParameters()); + } + if (parameters.isEmpty()) { + return; + } + + for (Parameter parameter : parameters) { + if (parameter.getSchema() == null) { + continue; + } + + Schema parameterSchema = parameter.getSchema(); + + if (parameterSchema == null) { + continue; + } + String schemaName = resolveModelName(parameterSchema.getTitle(), + (operation.getOperationId() == null ? modelName : operation.getOperationId()) + "_" + parameter.getName() + "_parameter"); + // Recursively gather/make inline models within this schema if any + gatherInlineModels(parameterSchema, schemaName); + if (isModelNeeded(parameterSchema)) { + // If this schema should be split into its own model, do so + Schema refSchema = makeSchemaInComponents(schemaName, parameterSchema); + parameter.setSchema(refSchema); + } + } + } + + /** + * Recursively gather inline models that need to be generated and + * replace inline schemas with $ref to schema to-be-generated. + * + * @param schema target schema + * @param modelPrefix model name (usually the prefix of the inline model name) + */ + private void gatherInlineModels(Schema schema, String modelPrefix) { + if (schema.get$ref() != null) { + // if ref already, no inline schemas should be present but check for + // any to catch OpenAPI violations + if (isModelNeeded(schema) || "object".equals(schema.getType()) || + schema.getProperties() != null || schema.getAdditionalProperties() != null || + ModelUtils.isComposedSchema(schema)) { + LOGGER.error("Illegal schema found with $ref combined with other properties, no properties should be defined alongside a $ref:\n {}", schema); + } + return; + } + // Check object models / any type models / composed models for properties, + // if the schema has a type defined that is not "object" it should not define + // any properties + if (schema.getType() == null || "object".equals(schema.getType())) { + // Check properties and recurse, each property could be its own inline model + Map props = schema.getProperties(); + if (props != null) { + for (String propName : props.keySet()) { + Schema prop = props.get(propName); + + if (prop == null) { + continue; + } + + String schemaName = resolveModelName(prop.getTitle(), modelPrefix + "_" + propName); + // Recurse to create $refs for inner models + gatherInlineModels(prop, schemaName); + if (isModelNeeded(prop)) { + // If this schema should be split into its own model, do so + Schema refSchema = makeSchemaInComponents(schemaName, prop); + props.put(propName, refSchema); + } else if (ModelUtils.isComposedSchema(prop)) { + if (prop.getAllOf() != null && prop.getAllOf().size() == 1 && + !(((Schema) prop.getAllOf().get(0)).getType() == null || + "object".equals(((Schema) prop.getAllOf().get(0)).getType()))) { + // allOf with only 1 type (non-model) + LOGGER.info("allOf schema used by the property `{}` replaced by its only item (a type)", propName); + props.put(propName, (Schema) prop.getAllOf().get(0)); + } + } + } + } + // Check additionalProperties for inline models + if (schema.getAdditionalProperties() != null) { + if (schema.getAdditionalProperties() instanceof Schema) { + Schema inner = (Schema) schema.getAdditionalProperties(); + if (inner != null) { + String schemaName = resolveModelName(schema.getTitle(), modelPrefix + inlineSchemaOptions.get("MAP_ITEM_SUFFIX")); + // Recurse to create $refs for inner models + gatherInlineModels(inner, schemaName); + if (isModelNeeded(inner)) { + // If this schema should be split into its own model, do so + Schema refSchema = makeSchemaInComponents(schemaName, inner); + schema.setAdditionalProperties(refSchema); + } + } + } + } + } else if (schema.getProperties() != null) { + // If non-object type is specified but also properties + LOGGER.error("Illegal schema found with non-object type combined with properties, no properties should be defined:\n {}", schema); + return; + } else if (schema.getAdditionalProperties() != null) { + // If non-object type is specified but also additionalProperties + LOGGER.error("Illegal schema found with non-object type combined with additionalProperties, no additionalProperties should be defined:\n {}", schema); + return; + } + // Check array items + if (schema instanceof ArraySchema) { + ArraySchema array = (ArraySchema) schema; + Schema items = array.getItems(); + if (items == null) { + LOGGER.error("Illegal schema found with array type but no items, items must be defined for array schemas:\n {}", schema); + return; + } + String schemaName = resolveModelName(items.getTitle(), modelPrefix + inlineSchemaOptions.get("ARRAY_ITEM_SUFFIX")); + + // Recurse to create $refs for inner models + gatherInlineModels(items, schemaName); + + if (isModelNeeded(items)) { + // If this schema should be split into its own model, do so + Schema refSchema = makeSchemaInComponents(schemaName, items); + array.setItems(refSchema); + } + } + // Check allOf, anyOf, oneOf for inline models + if (ModelUtils.isComposedSchema(schema)) { + if (schema.getAllOf() != null) { + List newAllOf = new ArrayList<>(); + boolean atLeastOneModel = false; + for (Object inner : schema.getAllOf()) { + if (inner == null) { + continue; + } + String schemaName = resolveModelName(((Schema) inner).getTitle(), modelPrefix + "_allOf"); + // Recurse to create $refs for inner models + gatherInlineModels((Schema) inner, schemaName); + if (isModelNeeded((Schema) inner)) { + if (Boolean.TRUE.equals(refactorAllOfInlineSchemas)) { + Schema refSchema = makeSchemaInComponents(schemaName, (Schema) inner); + newAllOf.add(refSchema); // replace with ref + atLeastOneModel = true; + } else { // do not refactor allOf inline schemas + newAllOf.add((Schema) inner); + atLeastOneModel = true; + } + } else { + newAllOf.add((Schema) inner); + } + } + if (atLeastOneModel) { + schema.setAllOf(newAllOf); + } else { + // allOf is just one or more types only so do not generate the inline allOf model + if (schema.getAllOf().size() > 1) { + LOGGER.warn("allOf schema `{}` containing multiple types (not model) is not supported at the moment.", schema.getName()); + } else if (schema.getAllOf().size() != 1) { + // handle earlier in this function when looping through properties + LOGGER.error("allOf schema `{}` contains no items.", schema.getName()); + } + } + } + if (schema.getAnyOf() != null) { + List newAnyOf = new ArrayList<>(); + for (Object inner : schema.getAnyOf()) { + if (inner == null) { + continue; + } + String schemaName = resolveModelName(((Schema) inner).getTitle(), modelPrefix + "_anyOf"); + // Recurse to create $refs for inner models + gatherInlineModels((Schema) inner, schemaName); + if (isModelNeeded((Schema) inner)) { + Schema refSchema = makeSchemaInComponents(schemaName, (Schema) inner); + newAnyOf.add(refSchema); // replace with ref + } else { + newAnyOf.add((Schema) inner); + } + } + schema.setAnyOf(newAnyOf); + } + if (schema.getOneOf() != null) { + List newOneOf = new ArrayList<>(); + for (Object inner : schema.getOneOf()) { + if (inner == null) { + continue; + } + String schemaName = resolveModelName(((Schema) inner).getTitle(), modelPrefix + "_oneOf"); + // Recurse to create $refs for inner models + gatherInlineModels((Schema) inner, schemaName); + if (isModelNeeded((Schema) inner)) { + Schema refSchema = makeSchemaInComponents(schemaName, (Schema) inner); + newOneOf.add(refSchema); // replace with ref + } else { + newOneOf.add((Schema) inner); + } + } + schema.setOneOf(newOneOf); + } + } + // Check not schema + if (schema.getNot() != null) { + Schema not = schema.getNot(); + if (not != null) { + String schemaName = resolveModelName(schema.getTitle(), modelPrefix + "_not"); + // Recurse to create $refs for inner models + gatherInlineModels(not, schemaName); + if (isModelNeeded(not)) { + Schema refSchema = makeSchemaInComponents(schemaName, not); + schema.setNot(refSchema); + } + } + } + } + + private String resolveModelName(String title, String key) { + return title == null ? uniqueName(key) : uniqueName(title); + } + + private String getInlineSchemaName(PathItem.HttpMethod httpVerb, String pathname) { + if (pathname.startsWith("/")) { + pathname = pathname.substring(1); + } + String name = pathname.replace('/', '_') + .replaceAll("[{}]", ""); + + if (httpVerb == PathItem.HttpMethod.DELETE) { + name += "_delete"; + } else if (httpVerb == PathItem.HttpMethod.GET) { + name += "_get"; + } else if (httpVerb == PathItem.HttpMethod.HEAD) { + name += "_head"; + } else if (httpVerb == PathItem.HttpMethod.OPTIONS) { + name += "_options"; + } else if (httpVerb == PathItem.HttpMethod.PATCH) { + name += "_patch"; + } else if (httpVerb == PathItem.HttpMethod.POST) { + name += "_post"; + } else if (httpVerb == PathItem.HttpMethod.PUT) { + name += "_put"; + } else if (httpVerb == PathItem.HttpMethod.TRACE) { + name += "_trace"; + } + return name; + } + + /** + * Move schema to components (if new) and return $ref to schema or + * existing schema. + * + * @param name new schema name + * @param schema schema to move to components or find existing ref + * + * @return {@link Schema} $ref schema to new or existing schema + */ + private Schema makeSchemaInComponents(String name, Schema schema) { + String existing = matchGenerated(schema); + Schema refSchema; + if (existing != null) { + refSchema = new Schema().$ref(existing); + } else { + if (resolveInlineEnums && schema.getEnum() != null && schema.getEnum().size() > 0) { + LOGGER.warn("Model {} promoted to its own schema due to resolveInlineEnums=true", name); + } + name = addSchemas(name, schema); + refSchema = new Schema().$ref(name); + } + copyVendorExtensions(schema, refSchema); + + return refSchema; + } + + /** + * Copy vendor extensions from Model to another Model. + * + * @param source source property + * @param target target property + */ + private void copyVendorExtensions(Schema source, Schema target) { + Map vendorExtensions = source.getExtensions(); + if (vendorExtensions == null) { + return; + } + for (String extName : vendorExtensions.keySet()) { + target.addExtension(extName, vendorExtensions.get(extName)); + } + } + + private String matchGenerated(Schema model) { + try { + String json = structureMapper.writeValueAsString(model); + if (generatedSignature.containsKey(json)) { + return generatedSignature.get(json); + } + } catch (JsonProcessingException e) { + e.printStackTrace(); + } + + return null; + } + + /** + * Return false if model can be represented by primitives e.g. string, object + * without properties, array or map of other model (model container), etc. + *

+ * Return true if a model should be generated e.g. object with properties, + * enum, oneOf, allOf, anyOf, etc. + * + * @param schema target schema + */ + private boolean isModelNeeded(Schema schema) { + return isModelNeeded(schema, new HashSet<>()); + } + + /** + * Return false if model can be represented by primitives e.g. string, object + * without properties, array or map of other model (model container), etc. + *

+ * Return true if a model should be generated e.g. object with properties, + * enum, oneOf, allOf, anyOf, etc. + * + * @param schema target schema + * @param visitedSchemas Visited schemas + */ + private boolean isModelNeeded(Schema schema, Set visitedSchemas) { + if (visitedSchemas.contains(schema)) { // circular reference + return true; + } else { + visitedSchemas.add(schema); + } + + if (schema.getEnum() != null && !schema.getEnum().isEmpty()) { + return true; + } + if (schema.getType() == null || "object".equals(schema.getType())) { + // object or undeclared type with properties + if (schema.getProperties() != null && !schema.getProperties().isEmpty()) { + return true; + } + } + if (ModelUtils.isComposedSchema(schema)) { + // allOf, anyOf, oneOf + boolean isSingleAllOf = schema.getAllOf() != null && schema.getAllOf().size() == 1; + boolean isReadOnly = schema.getReadOnly() != null && schema.getReadOnly(); + boolean isNullable = schema.getNullable() != null && schema.getNullable(); + + if (isSingleAllOf && (isReadOnly || isNullable)) { + // Check if this composed schema only contains an allOf and a readOnly or nullable. + ComposedSchema c = new ComposedSchema(); + c.setAllOf(schema.getAllOf()); + c.setReadOnly(schema.getReadOnly()); + c.setNullable(schema.getNullable()); + if (schema.equals(c)) { + return isModelNeeded((Schema) schema.getAllOf().get(0), visitedSchemas); + } + } else if (isSingleAllOf && StringUtils.isNotEmpty(((Schema) schema.getAllOf().get(0)).get$ref())) { + // single allOf and it's a ref + return isModelNeeded((Schema) schema.getAllOf().get(0), visitedSchemas); + } + + if (schema.getAllOf() != null && !schema.getAllOf().isEmpty()) { + // check to ensure at least one of the allOf item is model + for (Object inner : schema.getAllOf()) { + if (isModelNeeded(ModelUtils.getReferencedSchema(openAPI, (Schema) inner), visitedSchemas)) { + return true; + } + } + // allOf items are all non-model (e.g. type: string) only + return false; + } + + if (schema.getAnyOf() != null && !schema.getAnyOf().isEmpty()) { + return true; + } + if (schema.getOneOf() != null && !schema.getOneOf().isEmpty()) { + return true; + } + } + + return false; + } + + /** + * Add the schemas to the components. + * + * @param name name of the inline schema + * @param schema inline schema + * + * @return the actual model name (based on inlineSchemaNameMapping if provided) + */ + private String addSchemas(String name, Schema schema) { + //check inlineSchemaNameMapping + if (inlineSchemaNameMapping.containsKey(name)) { + name = inlineSchemaNameMapping.get(name); + } + + addGenerated(name, schema); + openAPI.getComponents().addSchemas(name, schema); + if (!name.equals(schema.getTitle()) && !inlineSchemaNameMappingValues.contains(name)) { + LOGGER.info("Inline schema created as {}. To have complete control of the model name, set the `title` field or use the modelNameMapping option (e.g. --model-name-mappings {}=NewModel,ModelA=NewModelA in CLI) or inlineSchemaNameMapping option (--inline-schema-name-mappings {}=NewModel,ModelA=NewModelA in CLI).", name, name, name); + } + + uniqueNames.add(name); + + return name; + } + + private void addGenerated(String name, Schema model) { + try { + String json = structureMapper.writeValueAsString(model); + generatedSignature.put(json, name); + } catch (JsonProcessingException e) { + e.printStackTrace(); + } + } + + /** + * Generate a unique name for the input. + * + * @param name name to be processed to make sure it's unique + */ + private String uniqueName(final String name) { + if (openAPI.getComponents().getSchemas() == null) { // no schema has been created + return name; + } + + String uniqueName = name; + int count = 0; + while (true) { + if (!openAPI.getComponents().getSchemas().containsKey(uniqueName) && !uniqueNames.contains(uniqueName)) { + return uniqueName; + } + uniqueName = name + "_" + ++count; + } + } +} diff --git a/openapi-generator/src/main/resources/templates/java-micronaut/common/operationAnnotations.mustache b/openapi-generator/src/main/resources/templates/java-micronaut/common/operationAnnotations.mustache index 219a5f819a..b53030c5a4 100644 --- a/openapi-generator/src/main/resources/templates/java-micronaut/common/operationAnnotations.mustache +++ b/openapi-generator/src/main/resources/templates/java-micronaut/common/operationAnnotations.mustache @@ -88,7 +88,7 @@ {{closebrace}}{{/vendorExtensions.originalParams.1}}{{/vendorExtensions.hasNotBodyParam}}{{#hasAuthMethods}}, security = {{#authMethods.1}}{{openbrace}} {{/authMethods.1}}{{#authMethods}}{{#authMethods.1}} - {{/authMethods.1}}@SecurityRequirement(name = "{{name}}"{{#isOAuth}}, scopes = {{openbrace}}{{#scopes}}"{{scope}}"{{^-last}}, {{/-last}}{{/scopes}}{{closebrace}}{{/isOAuth}}){{^-last}},{{/-last}} + {{/authMethods.1}}@SecurityRequirement(name = "{{name}}"{{#isOAuth}}{{#scopes.1}}, scopes = {{openbrace}}{{#scopes}}"{{scope}}"{{^-last}}, {{/-last}}{{/scopes}}{{closebrace}}{{/scopes.1}}{{/isOAuth}}){{^-last}},{{/-last}} {{/authMethods}}{{#authMethods.1}} {{closebrace}}{{/authMethods.1}}{{/hasAuthMethods}} ) diff --git a/openapi-generator/src/main/resources/templates/kotlin-micronaut/common/operationAnnotations.mustache b/openapi-generator/src/main/resources/templates/kotlin-micronaut/common/operationAnnotations.mustache index e0e01809af..49e4873277 100644 --- a/openapi-generator/src/main/resources/templates/kotlin-micronaut/common/operationAnnotations.mustache +++ b/openapi-generator/src/main/resources/templates/kotlin-micronaut/common/operationAnnotations.mustache @@ -59,7 +59,7 @@ ]{{/vendorExtensions.hasNotBodyParam}}{{#hasAuthMethods}}, security = [ {{#authMethods}} - SecurityRequirement(name = "{{name}}"{{#isOAuth}}, scopes = [{{#scopes}}"{{scope}}"{{^-last}}, {{/-last}}{{/scopes}}]{{/isOAuth}}){{^-last}},{{/-last}} + SecurityRequirement(name = "{{name}}"{{#isOAuth}}{{#scopes.1}}, scopes = [{{#scopes}}"{{scope}}"{{^-last}}, {{/-last}}{{/scopes}}]{{/scopes.1}}{{/isOAuth}}){{^-last}},{{/-last}} {{/authMethods}} ]{{/hasAuthMethods}} ) diff --git a/openapi-generator/src/test/java/io/micronaut/openapi/generator/JavaMicronautServerCodegenTest.java b/openapi-generator/src/test/java/io/micronaut/openapi/generator/JavaMicronautServerCodegenTest.java index e54cf6f8a1..b4ffb549ef 100644 --- a/openapi-generator/src/test/java/io/micronaut/openapi/generator/JavaMicronautServerCodegenTest.java +++ b/openapi-generator/src/test/java/io/micronaut/openapi/generator/JavaMicronautServerCodegenTest.java @@ -367,4 +367,17 @@ void testPluralBodyParamName() { assertFileContains(apiPath + "DefaultApi.java", "@Body @NotNull List<@Valid Book> books"); } + + @Test + void testControllerEnums() { + + var codegen = new JavaMicronautServerCodegen(); + String outputPath = generateFiles(codegen, "src/test/resources/3_0/controller-enum.yml", CodegenConstants.APIS, CodegenConstants.MODELS); + String modelPath = outputPath + "src/main/java/org/openapitools/model/"; + + assertFileExists(modelPath + "GetTokenRequestGrantType.java"); + assertFileExists(modelPath + "GetTokenRequestClientSecret.java"); + assertFileExists(modelPath + "GetTokenRequestClientId.java"); + assertFileExists(modelPath + "ArtistsArtistIdDirectAlbumsGetSortByParameter.java"); + } } diff --git a/openapi-generator/src/test/java/io/micronaut/openapi/generator/KotlinMicronautClientCodegenTest.java b/openapi-generator/src/test/java/io/micronaut/openapi/generator/KotlinMicronautClientCodegenTest.java index 43e9773a25..271c42b34c 100644 --- a/openapi-generator/src/test/java/io/micronaut/openapi/generator/KotlinMicronautClientCodegenTest.java +++ b/openapi-generator/src/test/java/io/micronaut/openapi/generator/KotlinMicronautClientCodegenTest.java @@ -169,7 +169,7 @@ data class Pet ( @field:Nullable @field:JsonProperty(JSON_PROPERTY_STATUS) @field:JsonInclude(JsonInclude.Include.USE_DEFAULTS) - var status: Status? = null, + var status: PetStatus? = null, ) { """); } @@ -321,7 +321,7 @@ open class BookInfo ( @field:Nullable @field:JsonProperty(JSON_PROPERTY_TYPE) @field:JsonInclude(JsonInclude.Include.USE_DEFAULTS) - open var type: Type? = null, + open var type: BookInfoType? = null, ) { """); assertFileContains(apiPath + "ExtendedBookInfo.kt", @@ -388,7 +388,7 @@ open class BookInfo ( open var name: String, @field:NotNull @field:JsonProperty(JSON_PROPERTY_TYPE) - open var type: Type? = null, + open var type: BookInfoType? = null, ) {"""); assertFileContains(apiPath + "BasicBookInfo.kt", """ diff --git a/openapi-generator/src/test/java/io/micronaut/openapi/generator/KotlinMicronautServerCodegenTest.java b/openapi-generator/src/test/java/io/micronaut/openapi/generator/KotlinMicronautServerCodegenTest.java index e3f7b7236e..478221bad8 100644 --- a/openapi-generator/src/test/java/io/micronaut/openapi/generator/KotlinMicronautServerCodegenTest.java +++ b/openapi-generator/src/test/java/io/micronaut/openapi/generator/KotlinMicronautServerCodegenTest.java @@ -153,10 +153,10 @@ data class Pet ( @field:JsonInclude(JsonInclude.Include.USE_DEFAULTS) var tags: List<@Valid Tag>? = null, @field:Nullable - @field:Schema(name = "status", description = "pet status in the store", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @field:Schema(name = "status", requiredMode = Schema.RequiredMode.NOT_REQUIRED) @field:JsonProperty(JSON_PROPERTY_STATUS) @field:JsonInclude(JsonInclude.Include.USE_DEFAULTS) - var status: Status? = null, + var status: PetStatus? = null, ) { """); } @@ -369,7 +369,7 @@ open class BookInfo ( @field:Schema(name = "type", requiredMode = Schema.RequiredMode.NOT_REQUIRED) @field:JsonProperty(JSON_PROPERTY_TYPE) @field:JsonInclude(JsonInclude.Include.USE_DEFAULTS) - open var type: Type? = null, + open var type: BookInfoType? = null, ) { """); assertFileContains(apiPath + "ExtendedBookInfo.kt", @@ -410,7 +410,7 @@ open class BookInfo ( @field:NotNull @field:Schema(name = "type", requiredMode = Schema.RequiredMode.REQUIRED) @field:JsonProperty(JSON_PROPERTY_TYPE) - open var type: Type? = null, + open var type: BookInfoType? = null, ) { """); assertFileContains(apiPath + "BasicBookInfo.kt", @@ -470,4 +470,17 @@ void testPluralBodyParamName() { assertFileContains(apiPath + "DefaultApi.kt", "@Body @NotNull books: List<@Valid Book>"); } + + @Test + void testControllerEnums() { + + var codegen = new KotlinMicronautServerCodegen(); + String outputPath = generateFiles(codegen, "src/test/resources/3_0/controller-enum.yml", CodegenConstants.APIS, CodegenConstants.MODELS); + String modelPath = outputPath + "src/main/kotlin/org/openapitools/model/"; + + assertFileExists(modelPath + "GetTokenRequestGrantType.kt"); + assertFileExists(modelPath + "GetTokenRequestClientSecret.kt"); + assertFileExists(modelPath + "GetTokenRequestClientId.kt"); + assertFileExists(modelPath + "ArtistsArtistIdDirectAlbumsGetSortByParameter.kt"); + } } diff --git a/openapi-generator/src/test/resources/3_0/controller-enum.yml b/openapi-generator/src/test/resources/3_0/controller-enum.yml new file mode 100644 index 0000000000..bc69bfa8cf --- /dev/null +++ b/openapi-generator/src/test/resources/3_0/controller-enum.yml @@ -0,0 +1,113 @@ +openapi: 3.0.1 +info: + title: test + description: desc + version: 0.0.1 +servers: + - url: https://cors-proxy.onrender.com/https://api.music.mundex.net:443/ + - url: https://api.music.mundex.net:443/ + - url: http://api.music.mundex.net:443/ +tags: + - name: "user" +paths: + /token/my/token: + servers: + - url: https://cors-proxy.onrender.com/https://oauth.mundex.ru + - url: https://oauth.mundex.ru + post: + summary: Get token + operationId: getToken + tags: + - "user" + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: + - "grant_type" + - "client_id" + - "client_secret" + - "username" + - "password" + properties: + grant_type: + type: string + enum: ["password"] + client_id: + type: string + enum: ["23cabbbdc6cd418abb4b39c32c41195d"] + client_secret: + type: string + enum: ["53bc75238f0c4d08a118e51fe9203300"] + username: + type: string + password: + type: string + obj: + type: object + properties: + prop1: + type: integer + prop2: + type: string + prop3: + type: boolean + responses: + 200: + description: Ok + content: + application/json: + schema: + type: object + required: + - "access_token" + - "expires_in" + - "token_type" + - "uid" + properties: + access_token: + type: string + expires_in: + type: number + token_type: + type: string + uid: + type: number + /artists/{artistId}/direct-albums: + parameters: + - in: path + required: true + name: artistId + schema: + type: string + example: 218099 + - in: query + required: false + name: page + schema: + type: number + example: 0 + - in: query + required: false + name: page-size + schema: + type: number + example: 20 + - in: query + required: false + name: sort-by + schema: + type: string + enum: + - "year" + - "rating" + get: + # TODO: add operationId + tags: + - "artists" + responses: + 200: + description: Ok + content: {} diff --git a/test-suite-java-server-generator/src/test/groovy/io/micronaut/openapi/test/api/RequestBodyControllerSpec.groovy b/test-suite-java-server-generator/src/test/groovy/io/micronaut/openapi/test/api/RequestBodyControllerSpec.groovy index 1d86156fff..e44ff98a22 100644 --- a/test-suite-java-server-generator/src/test/groovy/io/micronaut/openapi/test/api/RequestBodyControllerSpec.groovy +++ b/test-suite-java-server-generator/src/test/groovy/io/micronaut/openapi/test/api/RequestBodyControllerSpec.groovy @@ -14,6 +14,7 @@ import io.micronaut.openapi.test.model.Bird import io.micronaut.openapi.test.model.ColorEnum import io.micronaut.openapi.test.model.DateModel import io.micronaut.openapi.test.model.Mammal +import io.micronaut.openapi.test.model.MammalOrder import io.micronaut.openapi.test.model.ModelWithEnumList import io.micronaut.openapi.test.model.ModelWithInnerEnum import io.micronaut.openapi.test.model.ModelWithMapProperty @@ -191,7 +192,7 @@ class RequestBodyControllerSpec extends Specification { ModelWithInnerEnum model = new ModelWithInnerEnum() .speciesName("Short-eared rock wallaby") .numRepresentatives(40000L) - .mammalOrder(ModelWithInnerEnum.MammalOrderEnum.MARSUPIAL) + .mammalOrder(MammalOrder.MARSUPIAL) HttpRequest request = HttpRequest.POST("/sendModelWithInnerEnum", model) .contentType(MediaType.APPLICATION_JSON_TYPE) diff --git a/test-suite-kotlin-kapt-server-generator/src/test/kotlin/io/micronaut/openapi/test/api/RequestBodyControllerTest.kt b/test-suite-kotlin-kapt-server-generator/src/test/kotlin/io/micronaut/openapi/test/api/RequestBodyControllerTest.kt index 3b44f9528e..183eafc93a 100644 --- a/test-suite-kotlin-kapt-server-generator/src/test/kotlin/io/micronaut/openapi/test/api/RequestBodyControllerTest.kt +++ b/test-suite-kotlin-kapt-server-generator/src/test/kotlin/io/micronaut/openapi/test/api/RequestBodyControllerTest.kt @@ -114,7 +114,7 @@ class RequestBodyControllerTest( @Test fun testSendModelWithInnerEnum() { - val model = ModelWithInnerEnum("Short-eared rock wallaby", 40000L, ModelWithInnerEnum.MammalOrder.MARSUPIAL) + val model = ModelWithInnerEnum("Short-eared rock wallaby", 40000L, MammalOrder.MARSUPIAL) val request = HttpRequest.POST("/sendModelWithInnerEnum", model) .contentType(MediaType.APPLICATION_JSON_TYPE) val response = client.retrieve(request, Argument.of(String::class.java), Argument.of(String::class.java)) diff --git a/test-suite-kotlin-ksp-server-generator/src/test/kotlin/io/micronaut/openapi/test/api/RequestBodyControllerTest.kt b/test-suite-kotlin-ksp-server-generator/src/test/kotlin/io/micronaut/openapi/test/api/RequestBodyControllerTest.kt index 5d8c85cad7..3989bd56c6 100644 --- a/test-suite-kotlin-ksp-server-generator/src/test/kotlin/io/micronaut/openapi/test/api/RequestBodyControllerTest.kt +++ b/test-suite-kotlin-ksp-server-generator/src/test/kotlin/io/micronaut/openapi/test/api/RequestBodyControllerTest.kt @@ -114,7 +114,7 @@ class RequestBodyControllerTest( @Test fun testSendModelWithInnerEnum() { - val model = ModelWithInnerEnum("Short-eared rock wallaby", 40000L, ModelWithInnerEnum.MammalOrder.MARSUPIAL) + val model = ModelWithInnerEnum("Short-eared rock wallaby", 40000L, MammalOrder.MARSUPIAL) val request = HttpRequest.POST("/sendModelWithInnerEnum", model) .contentType(MediaType.APPLICATION_JSON_TYPE) val response = client.retrieve(request, Argument.of(String::class.java), Argument.of(String::class.java))