diff --git a/modules/swagger-codegen/pom.xml b/modules/swagger-codegen/pom.xml index 514ecebd322..621138791d9 100644 --- a/modules/swagger-codegen/pom.xml +++ b/modules/swagger-codegen/pom.xml @@ -190,6 +190,7 @@ 1.3.0 2.4.46 + 1.18.38 @@ -312,5 +313,15 @@ 2.27.2 test + + org.projectlombok + lombok + ${lombok.version} + + + com.github.ben-manes.caffeine + caffeine + 2.9.3 + diff --git a/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/CodegenConfig.java b/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/CodegenConfig.java index 3754b1da4a3..2931a743e59 100644 --- a/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/CodegenConfig.java +++ b/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/CodegenConfig.java @@ -246,6 +246,10 @@ public interface CodegenConfig { void setUnflattenedOpenAPI(OpenAPI unflattenedOpenAPI); + Map inlineSchemaNameMapping(); + + Map inlineSchemaOption(); + boolean getIgnoreImportMapping(); void setIgnoreImportMapping(boolean ignoreImportMapping); @@ -269,4 +273,7 @@ default int getPriority() { default String getCodeName() { return getName(); } + + Schema unaliasSchema(Schema schema); + } diff --git a/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/CodegenType.java b/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/CodegenType.java index 4cbea605282..886494015d3 100644 --- a/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/CodegenType.java +++ b/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/CodegenType.java @@ -7,7 +7,7 @@ import java.util.Map; public enum CodegenType { - CLIENT, SERVER, DOCUMENTATION, CONFIG, OTHER; + CLIENT, SERVER, DOCUMENTATION, CONFIG,SCHEMA, OTHER; private static Map names = new HashMap(); @@ -31,6 +31,7 @@ public String toValue() { names.put("client", CLIENT); names.put("server", SERVER); names.put("documentation", DOCUMENTATION); + names.put("schema", SCHEMA); names.put("config", CONFIG); names.put("other", OTHER); } diff --git a/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/DefaultGenerator.java b/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/DefaultGenerator.java index 94acb4bcbac..d815cdaa0e3 100644 --- a/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/DefaultGenerator.java +++ b/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/DefaultGenerator.java @@ -80,6 +80,18 @@ public Generator opts(ClientOptInput opts) { this.openAPI = opts.getOpenAPI(); this.config = opts.getConfig(); this.config.additionalProperties().putAll(opts.getOpts().getProperties()); + // resolve inline models + if (true) { + InlineModelResolver inlineModelResolver = new InlineModelResolver(); + inlineModelResolver.setInlineSchemaNameMapping(config.inlineSchemaNameMapping()); + inlineModelResolver.setInlineSchemaOptions(config.inlineSchemaOption()); + + inlineModelResolver.flatten(openAPI); + } + + // set OpenAPI to make these available to all methods + Map allDefinitions = openAPI.getComponents().getSchemas(); + Set modelKeys = allDefinitions.keySet(); String ignoreFileLocation = this.config.getIgnoreFilePathOverride(); if(ignoreFileLocation != null) { diff --git a/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/InlineModelResolver.java b/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/InlineModelResolver.java new file mode 100644 index 00000000000..ecba64817db --- /dev/null +++ b/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/InlineModelResolver.java @@ -0,0 +1,1062 @@ +/* + * Copyright 2018 OpenAPI-Generator Contributors (https://openapi-generator.tech) + * Copyright 2018 SmartBear Software + * + * 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.swagger.codegen.v3; + +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; +import io.swagger.codegen.v3.utils.ModelUtils; +import io.swagger.v3.core.util.Json; +import io.swagger.v3.oas.models.*; +import io.swagger.v3.oas.models.PathItem.HttpMethod; +import io.swagger.v3.oas.models.callbacks.Callback; +import io.swagger.v3.oas.models.media.*; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.parameters.RequestBody; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; + +public class InlineModelResolver { + 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<>(); + public boolean resolveInlineEnums = false; + public boolean skipSchemaReuse = false; // skip reusing inline schema if set to true + public Boolean refactorAllOfInlineSchemas = null; // refactor allOf inline schemas into $ref + + // structure mapper sorts properties alphabetically on write to ensure models are + // serialized consistently for lookup of existing models + private static ObjectMapper structureMapper; + + // a set to keep track of names generated for inline schemas + private Set uniqueNames = new HashSet<>(); + + static { + structureMapper = Json.mapper().copy(); + structureMapper.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true); + structureMapper.writer(new DefaultPrettyPrinter()); + } + + final Logger LOGGER = LoggerFactory.getLogger(InlineModelResolver.class); + + public InlineModelResolver() { + this.inlineSchemaOptions.put("ARRAY_ITEM_SUFFIX", "_inner"); + this.inlineSchemaOptions.put("MAP_ITEM_SUFFIX", "_value"); + } + + public void setInlineSchemaNameMapping(Map inlineSchemaNameMapping) { + this.inlineSchemaNameMapping = inlineSchemaNameMapping; + this.inlineSchemaNameMappingValues = new HashSet<>(inlineSchemaNameMapping.values()); + } + + public void setInlineSchemaOptions(Map inlineSchemaOptions) { + this.inlineSchemaOptions.putAll(inlineSchemaOptions); + + if ("true".equalsIgnoreCase( + this.inlineSchemaOptions.getOrDefault("SKIP_SCHEMA_REUSE", "false"))) { + this.skipSchemaReuse = true; + } + + if (this.inlineSchemaOptions.containsKey("REFACTOR_ALLOF_INLINE_SCHEMAS")) { + this.refactorAllOfInlineSchemas = Boolean.valueOf(this.inlineSchemaOptions.get("REFACTOR_ALLOF_INLINE_SCHEMAS")); + } else { + // not set so default to null; + } + + if (this.inlineSchemaOptions.containsKey("RESOLVE_INLINE_ENUMS")) { + this.resolveInlineEnums = Boolean.valueOf(this.inlineSchemaOptions.get("RESOLVE_INLINE_ENUMS")); + } else { + // not set so default to null; + } + } + + public void flatten(OpenAPI openAPI) { + this.openAPI = openAPI; + + if (this.openAPI.getComponents() == null) { + this.openAPI.setComponents(new Components()); + } + + if (this.openAPI.getComponents().getSchemas() == null) { + this.openAPI.getComponents().setSchemas(new HashMap()); + } + + flattenPaths(); + flattenWebhooks(); + flattenComponents(); + flattenComponentResponses(); + } + + /** + * Flatten inline models in Webhooks + */ + private void flattenWebhooks() { + Map webhooks = openAPI.getWebhooks(); + if (webhooks == null) { + return; + } + flattenPathItems(webhooks); + } + + /** + * Flatten inline models in Paths + */ + private void flattenPaths() { + Paths paths = openAPI.getPaths(); + if (paths == null) { + return; + } + flattenPathItems(paths); + } + + /** + * Flatten inline models in path items + * + * @param pathItemMap Map of path items + */ + private void flattenPathItems(Map pathItemMap) { + for (Map.Entry pathsEntry : pathItemMap.entrySet()) { + PathItem path = pathsEntry.getValue(); + List> toFlatten = new ArrayList<>(path.readOperationsMap().entrySet()); + + // 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(); + toFlatten.addAll(pathItem.readOperationsMap().entrySet()); + } + } + } + } + + // flatten path-level parameters + flattenParameters(pathname, path.getParameters(), null); + + // flatten parameters for each operation + for (Map.Entry operationEntry : toFlatten) { + Operation operation = operationEntry.getValue(); + String inlineSchemaName = this.getInlineSchemaName(operationEntry.getKey(), pathname); + flattenRequestBody(inlineSchemaName, operation); + flattenParameters(inlineSchemaName, operation.getParameters(), operation.getOperationId()); + flattenResponses(inlineSchemaName, operation); + } + } + } + + private String getInlineSchemaName(HttpMethod httpVerb, String pathname) { + String name = pathname; + if (httpVerb.equals(HttpMethod.DELETE)) { + name += "_delete"; + } else if (httpVerb.equals(HttpMethod.GET)) { + name += "_get"; + } else if (httpVerb.equals(HttpMethod.HEAD)) { + name += "_head"; + } else if (httpVerb.equals(HttpMethod.OPTIONS)) { + name += "_options"; + } else if (httpVerb.equals(HttpMethod.PATCH)) { + name += "_patch"; + } else if (httpVerb.equals(HttpMethod.POST)) { + name += "_post"; + } else if (httpVerb.equals(HttpMethod.PUT)) { + name += "_put"; + } else if (httpVerb.equals(HttpMethod.TRACE)) { + name += "_trace"; + } else { + // no HTTP verb defined? + // throw new RuntimeException("No HTTP verb found/detected in the inline model + // resolver"); + } + return name; + } + + /** + * 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 (resolveInlineEnums && schema.getEnum() != null && schema.getEnum().size() > 0) { + return true; + } + if (schema.getType() == null || "object".equals(schema.getType())) { + // object or undeclared type with properties + if (schema.getProperties() != null && schema.getProperties().size() > 0) { + 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); + } + } + + 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; + } + + /** + * 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.toString()); + } + 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 = this.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(inner.getTitle(), modelPrefix + this.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 = this.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.toString()); + 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.toString()); + return; + } + // Check array items + if (ModelUtils.isArraySchema(schema)) { + Schema items = ModelUtils.getSchemaItems(schema); + if (items == null && schema.getPrefixItems() == null) { + LOGGER.debug("Incorrect array schema with no items, prefixItems: {}", schema.toString()); + return; + } + + if (items == null) { + LOGGER.debug("prefixItems in array schema is not supported at the moment: {}", schema.toString()); + return; + } + String schemaName = resolveModelName(items.getTitle(), modelPrefix + this.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.setItems(this.makeSchemaInComponents(schemaName, items)); + } + } + // 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(this.refactorAllOfInlineSchemas)) { + newAllOf.add(this.makeSchemaInComponents(schemaName, (Schema) inner)); // 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) { + // handle earlier in this function when looping through properties + } else if (schema.getAllOf().size() > 1) { + LOGGER.warn("allOf schema `{}` containing multiple types (not model) is not supported at the moment.", schema.getName()); + } else { + 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)) { + newAnyOf.add(this.makeSchemaInComponents(schemaName, (Schema) inner)); // 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)) { + newOneOf.add(this.makeSchemaInComponents(schemaName, (Schema) inner)); // 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 = this.makeSchemaInComponents(schemaName, not); + schema.setNot(refSchema); + } + } + } + } + + /** + * Flatten inline models in content + * + * @param content target content + * @param name backup name if no title is found + */ + private void flattenContent(Content content, String name) { + if (content == null || content.isEmpty()) { + return; + } + + for (String contentType : content.keySet()) { + MediaType mediaType = content.get(contentType); + if (mediaType == null) { + continue; + } + Schema schema = mediaType.getSchema(); + if (schema == null) { + continue; + } + String schemaName = resolveModelName(schema.getTitle(), name); // name example: testPost_request + // Recursively gather/make inline models within this schema if any + gatherInlineModels(schema, schemaName); + if (isModelNeeded(schema)) { + // If this schema should be split into its own model, do so + //Schema refSchema = this.makeSchema(schemaName, schema); + mediaType.setSchema(this.makeSchemaInComponents(schemaName, schema)); + } + } + } + + /** + * Flatten inline models in RequestBody + * + * @param modelName inline model name prefix + * @param operation target operation + */ + private void flattenRequestBody(String modelName, Operation operation) { + RequestBody requestBody = operation.getRequestBody(); + if (requestBody == null) { + return; + } + + // unalias $ref + if (requestBody.get$ref() != null) { + String ref = ModelUtils.getSimpleRef(requestBody.get$ref()); + requestBody = openAPI.getComponents().getRequestBodies().get(ref); + + if (requestBody == null) { + return; + } + } + + flattenContent(requestBody.getContent(), + (operation.getOperationId() == null ? modelName : operation.getOperationId()) + "_request"); + } + + /** + * Flatten inline models in parameters + * + * @param modelName model name + * @param parameters list of parameters + * @param operationId operation Id (optional) + */ + private void flattenParameters(String modelName, List parameters, String operationId) { + //List parameters = operation.getParameters(); + if (parameters == null) { + return; + } + + for (Parameter parameter : parameters) { + if (StringUtils.isNotEmpty(parameter.get$ref())) { + parameter = ModelUtils.getReferencedParameter(openAPI, parameter); + } + + if (parameter.getSchema() == null) { + continue; + } + + Schema parameterSchema = parameter.getSchema(); + + if (parameterSchema == null) { + continue; + } + String schemaName = resolveModelName(parameterSchema.getTitle(), + (operationId == null ? modelName : operationId) + "_" + 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 + parameter.setSchema(this.makeSchemaInComponents(schemaName, parameterSchema)); + } + } + } + + /** + * Flatten inline models in ApiResponses + * + * @param modelName model name prefix + * @param operation target operation + */ + private void flattenResponses(String modelName, Operation operation) { + ApiResponses responses = operation.getResponses(); + if (responses == null) { + return; + } + + for (Map.Entry responsesEntry : responses.entrySet()) { + String key = responsesEntry.getKey(); + ApiResponse response = responsesEntry.getValue(); + + flattenContent(response.getContent(), + (operation.getOperationId() == null ? modelName : operation.getOperationId()) + "_" + key + "_response"); + } + } + + /** + * Flatten inline models in the responses section in the components. + */ + private void flattenComponentResponses() { + Map apiResponses = openAPI.getComponents().getResponses(); + if (apiResponses == null) { + return; + } + + for (Map.Entry entry : apiResponses.entrySet()) { + flattenContent(entry.getValue().getContent(), null); + } + } + + /** + * Flattens properties of inline object schemas that belong to a composed schema into a + * single flat list of properties. This is useful to generate a single or multiple + * inheritance model. + *

+ * In the example below, codegen may generate a 'Dog' class that extends from the + * generated 'Animal' class. 'Dog' has additional properties 'name', 'age' and 'breed' that + * are flattened as a single list of properties. + *

+ * Dog: + * allOf: + * - $ref: '#/components/schemas/Animal' + * - type: object + * properties: + * name: + * type: string + * age: + * type: string + * - type: object + * properties: + * breed: + * type: string + * + * @param key a unique name for the composed schema. + * @param children the list of nested schemas within a composed schema (allOf, anyOf, oneOf). + * @param skipAllOfInlineSchemas true if allOf inline schemas need to be skipped. + */ + private void flattenComposedChildren(String key, List children, boolean skipAllOfInlineSchemas) { + if (children == null || children.isEmpty()) { + return; + } + ListIterator listIterator = children.listIterator(); + while (listIterator.hasNext()) { + Schema component = listIterator.next(); + if ((component != null) && + (component.get$ref() == null) && + ((component.getProperties() != null && !component.getProperties().isEmpty()) || + (component.getEnum() != null && !component.getEnum().isEmpty()))) { + // If a `title` attribute is defined in the inline schema, codegen uses it to name the + // inline schema. Otherwise, we'll use the default naming such as InlineObject1, etc. + // We know that this is not the best way to name the model. + // + // Such naming strategy may result in issues. If the value of the 'title' attribute + // happens to match a schema defined elsewhere in the specification, 'innerModelName' + // will be the same as that other schema. + // + // To have complete control of the model naming, one can define the model separately + // instead of inline. + String innerModelName = resolveModelName(component.getTitle(), key); + Schema innerModel = modelFromProperty(openAPI, component, innerModelName); + // Recurse to create $refs for inner models + gatherInlineModels(innerModel, innerModelName); + if (!skipAllOfInlineSchemas) { + String existing = matchGenerated(innerModel); + if (existing == null) { + innerModelName = addSchemas(innerModelName, innerModel); + Schema schema = new Schema().$ref(innerModelName); + schema.setRequired(component.getRequired()); + listIterator.set(schema); + } else { + Schema schema = new Schema().$ref(existing); + schema.setRequired(component.getRequired()); + listIterator.set(schema); + } + } else { + LOGGER.debug("Inline allOf schema {} not refactored into a separate model using $ref.", innerModelName); + } + } + } + } + + /** + * Flatten inline models in components + */ + private void flattenComponents() { + Map models = openAPI.getComponents().getSchemas(); + if (models == null) { + return; + } + + List modelNames = new ArrayList(models.keySet()); + for (String modelName : modelNames) { + Schema model = models.get(modelName); + if (model == null) { + continue; + } + if (ModelUtils.isAnyOf(model)) { // contains anyOf only + gatherInlineModels(model, modelName); + } else if (ModelUtils.isOneOf(model)) { // contains oneOf only + gatherInlineModels(model, modelName); + } else if (ModelUtils.isComposedSchema(model)) { + // inline child schemas + flattenComposedChildren(modelName + "_allOf", model.getAllOf(), !Boolean.TRUE.equals(this.refactorAllOfInlineSchemas)); + flattenComposedChildren(modelName + "_anyOf", model.getAnyOf(), false); + flattenComposedChildren(modelName + "_oneOf", model.getOneOf(), false); + } else { + gatherInlineModels(model, modelName); + } + } + } + + /** + * This function fix models that are string (mostly enum). Before this fix, the + * example would look something like that in the doc: "\"example from def\"" + * + * @param m Schema implementation + */ + private void fixStringModel(Schema m) { + if (schemaIsOfType(m, "string") && schemaContainsExample(m)) { + String example = m.getExample().toString(); + if (example.startsWith("\"") && example.endsWith("\"")) { + m.setExample(example.substring(1, example.length() - 1)); + } + } + } + + private boolean schemaIsOfType(Schema m, String type) { + return m.getType() != null && m.getType().equals(type); + } + + private boolean schemaContainsExample(Schema m) { + return m.getExample() != null && m.getExample() != ""; + } + + /** + * Generates a unique model name. Non-alphanumeric characters will be replaced + * with underscores + *

+ * e.g. io.schema.User_name => io_schema_User_name + * + * @param title String title field in the schema if present + * @param modelName String model name + * @return if provided the sanitized {@code title}, else the sanitized {@code key} + */ + private String resolveModelName(String title, String modelName) { + if (title == null || "".equals(sanitizeName(title).replace("_", ""))) { + if (modelName == null) { + return uniqueName("inline_object"); + } + return uniqueName(sanitizeName(modelName)); + } else { + return uniqueName(sanitizeName(title)); + } + } + + private String matchGenerated(Schema model) { + if (skipSchemaReuse) { // skip reusing schema + return null; + } + + try { + String json = structureMapper.writeValueAsString(model); + if (generatedSignature.containsKey(json)) { + return generatedSignature.get(json); + } + } catch (JsonProcessingException e) { + e.printStackTrace(); + } + + return null; + } + + private void addGenerated(String name, Schema model) { + try { + String json = structureMapper.writeValueAsString(model); + generatedSignature.put(json, name); + } catch (JsonProcessingException e) { + e.printStackTrace(); + } + } + + /** + * Sanitizes the input so that it's valid name for a class or interface + *

+ * e.g. 12.schema.User name => _2_schema_User_name + * + * @param name name to be processed to make sure it's sanitized + */ + private String sanitizeName(final String name) { + return name + .replaceAll("^[0-9]", "_$0") // e.g. 12object => _12object + .replaceAll("[^A-Za-z0-9]", "_"); // e.g. io.schema.User name => io_schema_User_name + } + + /** + * 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; + } + } + + private void flattenProperties(OpenAPI openAPI, Map properties, String path) { + if (properties == null) { + return; + } + Map propsToUpdate = new HashMap(); + Map modelsToAdd = new HashMap(); + for (Map.Entry propertiesEntry : properties.entrySet()) { + String key = propertiesEntry.getKey(); + Schema property = propertiesEntry.getValue(); + if (ModelUtils.isObjectSchema(property)) { + Schema op = property; + String modelName = resolveModelName(op.getTitle(), path + "_" + key); + Schema model = modelFromProperty(openAPI, op, modelName); + String existing = matchGenerated(model); + if (existing != null) { + Schema schema = new Schema().$ref(existing); + schema.setRequired(op.getRequired()); + propsToUpdate.put(key, schema); + } else { + modelName = addSchemas(modelName, model); + Schema schema = new Schema().$ref(modelName); + schema.setRequired(op.getRequired()); + propsToUpdate.put(key, schema); + modelsToAdd.put(modelName, model); + } + } else if (ModelUtils.isArraySchema(property)) { + Schema inner = ModelUtils.getSchemaItems(property); + if (ModelUtils.isObjectSchema(inner)) { + Schema op = inner; + if (op.getProperties() != null && op.getProperties().size() > 0) { + flattenProperties(openAPI, op.getProperties(), path); + String modelName = resolveModelName(op.getTitle(), path + "_" + key); + Schema innerModel = modelFromProperty(openAPI, op, modelName); + String existing = matchGenerated(innerModel); + if (existing != null) { + Schema schema = new Schema().$ref(existing); + schema.setRequired(op.getRequired()); + property.setItems(schema); + } else { + modelName = addSchemas(modelName, innerModel); + Schema schema = new Schema().$ref(modelName); + schema.setRequired(op.getRequired()); + property.setItems(schema); + } + } + } else if (ModelUtils.isComposedSchema(inner)) { + String innerModelName = resolveModelName(inner.getTitle(), path + "_" + key); + gatherInlineModels(inner, innerModelName); + innerModelName = addSchemas(innerModelName, inner); + Schema schema = new Schema().$ref(innerModelName); + schema.setRequired(inner.getRequired()); + property.setItems(schema); + } else { + LOGGER.debug("Schema not yet handled in model resolver: {}", inner); + } + } else if (ModelUtils.isMapSchema(property)) { + Schema inner = ModelUtils.getAdditionalProperties(property); + if (ModelUtils.isObjectSchema(inner)) { + Schema op = inner; + if (op.getProperties() != null && op.getProperties().size() > 0) { + flattenProperties(openAPI, op.getProperties(), path); + String modelName = resolveModelName(op.getTitle(), path + "_" + key); + Schema innerModel = modelFromProperty(openAPI, op, modelName); + String existing = matchGenerated(innerModel); + if (existing != null) { + Schema schema = new Schema().$ref(existing); + schema.setRequired(op.getRequired()); + property.setAdditionalProperties(schema); + } else { + modelName = addSchemas(modelName, innerModel); + Schema schema = new Schema().$ref(modelName); + schema.setRequired(op.getRequired()); + property.setAdditionalProperties(schema); + } + } + } else if (ModelUtils.isComposedSchema(inner)) { + String innerModelName = resolveModelName(inner.getTitle(), path + "_" + key); + gatherInlineModels(inner, innerModelName); + innerModelName = addSchemas(innerModelName, inner); + Schema schema = new Schema().$ref(innerModelName); + schema.setRequired(inner.getRequired()); + property.setAdditionalProperties(schema); + } else { + LOGGER.debug("Schema not yet handled in model resolver: {}", inner); + } + } else if (ModelUtils.isComposedSchema(property)) { // oneOf, anyOf, allOf etc + if (property.getAllOf() != null && property.getAllOf().size() == 1 // allOf with a single item + && (property.getOneOf() == null || property.getOneOf().isEmpty()) // not oneOf + && (property.getAnyOf() == null || property.getAnyOf().isEmpty()) // not anyOf + && (property.getProperties() == null || property.getProperties().isEmpty())) { // no property + // don't do anything if it's allOf with a single item + LOGGER.debug("allOf with a single item (which can be handled by default codegen) skipped by inline model resolver: {}", property); + } else { + String propertyModelName = resolveModelName(property.getTitle(), path + "_" + key); + gatherInlineModels(property, propertyModelName); + propertyModelName = addSchemas(propertyModelName, property); + Schema schema = new Schema().$ref(propertyModelName); + schema.setRequired(property.getRequired()); + propsToUpdate.put(key, schema); + } + } else { + LOGGER.debug("Schema not yet handled in model resolver: {}", property); + } + } + if (propsToUpdate.size() > 0) { + for (String key : propsToUpdate.keySet()) { + properties.put(key, propsToUpdate.get(key)); + } + } + for (String key : modelsToAdd.keySet()) { + openAPI.getComponents().addSchemas(key, modelsToAdd.get(key)); + this.addedModels.put(key, modelsToAdd.get(key)); + } + } + + private Schema modelFromProperty(OpenAPI openAPI, Schema object, String path) { + String description = object.getDescription(); + String example = null; + Object obj = object.getExample(); + if (obj != null) { + example = obj.toString(); + } + XML xml = object.getXml(); + Map properties = object.getProperties(); + + // NOTE: + // No need to null check setters below. All defaults in the new'd Schema are null, so setting to null would just be a noop. + Schema model = new Schema(); + model.setType(object.getType()); + + // Even though the `format` keyword typically applies to primitive types only, + // the JSON schema specification states `format` can be used for any model type instance + // including object types. + model.setFormat(object.getFormat()); + + if (object.getExample() != null) { + model.setExample(example); + } + model.setDescription(description); + model.setName(object.getName()); + model.setXml(xml); + model.setRequired(object.getRequired()); + model.setNullable(object.getNullable()); + model.setEnum(object.getEnum()); + model.setType(object.getType()); + model.setDiscriminator(object.getDiscriminator()); + model.setWriteOnly(object.getWriteOnly()); + model.setUniqueItems(object.getUniqueItems()); + model.setTitle(object.getTitle()); + model.setReadOnly(object.getReadOnly()); + model.setPattern(object.getPattern()); + model.setNot(object.getNot()); + model.setMinProperties(object.getMinProperties()); + model.setMinLength(object.getMinLength()); + model.setMinItems(object.getMinItems()); + model.setMinimum(object.getMinimum()); + model.setMaxProperties(object.getMaxProperties()); + model.setMaxLength(object.getMaxLength()); + model.setMaxItems(object.getMaxItems()); + model.setMaximum(object.getMaximum()); + model.setExternalDocs(object.getExternalDocs()); + model.setExtensions(object.getExtensions()); + model.setExclusiveMinimum(object.getExclusiveMinimum()); + model.setExclusiveMaximum(object.getExclusiveMaximum()); + // no need to set it again as it's set earlier + //model.setExample(object.getExample()); + model.setDeprecated(object.getDeprecated()); + + if (properties != null) { + flattenProperties(openAPI, properties, path); + model.setProperties(properties); + } + return model; + } + + /** + * 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 " + name + " promoted to its own schema due to resolveInlineEnums=true"); + } + name = addSchemas(name, schema); + refSchema = new Schema().$ref(name); + } + this.copyVendorExtensions(schema, refSchema); + + return refSchema; + } + + /** + * Make a Schema + * + * @param ref new property name + * @param property Schema + * @return {@link Schema} A constructed OpenAPI property + */ + private Schema makeSchema(String ref, Schema property) { + Schema newProperty = new Schema().$ref(ref); + this.copyVendorExtensions(property, newProperty); + return newProperty; + } + + /** + * 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)); + } + } + + /** + * 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; + } +} diff --git a/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/utils/GlobalSettings.java b/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/utils/GlobalSettings.java new file mode 100644 index 00000000000..ab14bf30123 --- /dev/null +++ b/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/utils/GlobalSettings.java @@ -0,0 +1,79 @@ +/* + * Copyright 2018 OpenAPI-Generator Contributors (https://openapi-generator.tech) + * + * 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.swagger.codegen.v3.utils; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Properties; + +/** + * GlobalSettings encapsulates SystemProperties, since the codegen mechanism heavily relies on a stable, + * non-changing System Property Basis. Using plain System.(get|set|clear)Property raises Race-Conditions in combination + * with Code, that uses System.setProperties (e.g. maven-surefire-plugin). + *

+ * This provides a set of properties specific to the executing thread, such that the generator may not modify system properties + * consumed by other threads. + * + * @author gndrm + * @since 2018 + */ +public class GlobalSettings { + + private static final Logger LOGGER = LoggerFactory.getLogger(GlobalSettings.class); + + private static ThreadLocal properties = new InheritableThreadLocal() { + @Override + protected Properties initialValue() { + // avoid using System.getProperties().clone() which is broken in Gradle - see https://github.com/gradle/gradle/issues/17344 + Properties copy = new Properties(); + System.getProperties() + .forEach((k,v) -> copy.put(String.valueOf(k), String.valueOf(v))); + return copy; + } + }; + + public static String getProperty(String key, String defaultValue) { + return properties.get().getProperty(key, defaultValue); + } + + public static String getProperty(String key) { + return properties.get().getProperty(key); + } + + public static void setProperty(String key, String value) { + properties.get().setProperty(key, value); + } + + public static void clearProperty(String key) { + properties.get().remove(key); + } + + public static void reset() { + properties.remove(); + } + + public static void log() { + if(LOGGER.isDebugEnabled()) { + StringWriter stringWriter = new StringWriter(); + properties.get().list(new PrintWriter(stringWriter)); + LOGGER.debug("GlobalSettings: {}", stringWriter); + } + } +} diff --git a/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/utils/ModelUtils.java b/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/utils/ModelUtils.java index 1908cde0ce9..6719c886d8c 100644 --- a/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/utils/ModelUtils.java +++ b/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/utils/ModelUtils.java @@ -1,21 +1,33 @@ package io.swagger.codegen.v3.utils; +import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.codegen.v3.CodegenConstants; import io.swagger.codegen.v3.CodegenModel; import io.swagger.codegen.v3.CodegenProperty; +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.callbacks.Callback; +import io.swagger.v3.oas.models.headers.Header; +import io.swagger.v3.oas.models.media.*; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.parameters.RequestBody; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.parser.ObjectMapperFactory; +import io.swagger.v3.parser.util.SchemaTypeUtil; import org.apache.commons.lang3.StringEscapeUtils; import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; +import static io.swagger.codegen.v3.utils.OnceLogger.once; public class ModelUtils { /** * Searches for the model by name in the map of models and returns it @@ -242,4 +254,1557 @@ private static String toEnumValue(String value, String datatype) { return String.format("\"%s\"", value); } } + + + private static final Logger LOGGER = LoggerFactory.getLogger(ModelUtils.class); + + private static final String URI_FORMAT = "uri"; + + private static final String generateAliasAsModelKey = "generateAliasAsModel"; + + // A vendor extension to track the value of the 'disallowAdditionalPropertiesIfNotPresent' CLI + private static final String disallowAdditionalPropertiesIfNotPresent = "x-disallow-additional-properties-if-not-present"; + + private static final String freeFormExplicit = "x-is-free-form"; + + private static final ObjectMapper JSON_MAPPER; + private static final ObjectMapper YAML_MAPPER; + + static { + JSON_MAPPER = ObjectMapperFactory.createJson(); + YAML_MAPPER = ObjectMapperFactory.createYaml(); + } + + public static boolean isDisallowAdditionalPropertiesIfNotPresent() { + return Boolean.parseBoolean(GlobalSettings.getProperty(disallowAdditionalPropertiesIfNotPresent, "true")); + } + + public static boolean isGenerateAliasAsModel() { + return Boolean.parseBoolean(GlobalSettings.getProperty(generateAliasAsModelKey, "false")); + } + + + public static boolean isGenerateAliasAsModel(Schema schema) { + return isGenerateAliasAsModel() || (schema.getExtensions() != null && schema.getExtensions().getOrDefault("x-generate-alias-as-model", false).equals(true)); + } + + /** + * Return the list of all schemas in the 'components/schemas' section used in the openAPI specification + * + * @param openAPI specification + * @return schemas a list of used schemas + */ + public static List getAllUsedSchemas(OpenAPI openAPI) { + Map> childrenMap = getChildrenMap(openAPI); + List allUsedSchemas = new ArrayList(); + visitOpenAPI(openAPI, (s, t) -> { + if (s.get$ref() != null) { + String ref = getSimpleRef(s.get$ref()); + if (!allUsedSchemas.contains(ref)) { + allUsedSchemas.add(ref); + } + if (childrenMap.containsKey(ref)) { + for (String child : childrenMap.get(ref)) { + if (!allUsedSchemas.contains(child)) { + allUsedSchemas.add(child); + } + } + } + } + }); + return allUsedSchemas; + } + + /** + * Return the list of unused schemas in the 'components/schemas' section of an openAPI specification + * + * @param openAPI specification + * @return schemas a list of unused schemas + */ + public static List getUnusedSchemas(OpenAPI openAPI) { + final Map> childrenMap; + Map> tmpChildrenMap; + try { + tmpChildrenMap = getChildrenMap(openAPI); + } catch (NullPointerException npe) { + // in rare cases, such as a spec document with only one top-level oneOf schema and multiple referenced schemas, + // the stream used in getChildrenMap will raise an NPE. Rather than modify getChildrenMap which is used by getAllUsedSchemas, + // we'll catch here as a workaround for this edge case. + tmpChildrenMap = new HashMap<>(); + } + + childrenMap = tmpChildrenMap; + List unusedSchemas = new ArrayList(); + + if (openAPI != null) { + Map schemas = getSchemas(openAPI); + unusedSchemas.addAll(schemas.keySet()); + + visitOpenAPI(openAPI, (s, t) -> { + if (s.get$ref() != null) { + String ref = getSimpleRef(s.get$ref()); + unusedSchemas.remove(ref); + if (childrenMap.containsKey(ref)) { + unusedSchemas.removeAll(childrenMap.get(ref)); + } + } + }); + } + return unusedSchemas; + } + + /** + * Return the list of schemas in the 'components/schemas' used only in a 'application/x-www-form-urlencoded' or 'multipart/form-data' mime time + * + * @param openAPI specification + * @return schemas a list of schemas + */ + public static List getSchemasUsedOnlyInFormParam(OpenAPI openAPI) { + List schemasUsedInFormParam = new ArrayList(); + List schemasUsedInOtherCases = new ArrayList(); + + visitOpenAPI(openAPI, (s, t) -> { + if (s != null && s.get$ref() != null) { + String ref = getSimpleRef(s.get$ref()); + if ("application/x-www-form-urlencoded".equalsIgnoreCase(t) || + "multipart/form-data".equalsIgnoreCase(t)) { + schemasUsedInFormParam.add(ref); + } else { + schemasUsedInOtherCases.add(ref); + } + } + }); + return schemasUsedInFormParam.stream().filter(n -> !schemasUsedInOtherCases.contains(n)).collect(Collectors.toList()); + } + + /** + * Private method used by several methods ({@link #getAllUsedSchemas(OpenAPI)}, + * {@link #getUnusedSchemas(OpenAPI)}, + * {@link #getSchemasUsedOnlyInFormParam(OpenAPI)}, ...) to traverse all paths of an + * OpenAPI instance and call the visitor functional interface when a schema is found. + * + * @param openAPI specification + * @param visitor functional interface (can be defined as a lambda) called each time a schema is found. + */ + private static void visitOpenAPI(OpenAPI openAPI, OpenAPISchemaVisitor visitor) { + Map paths = openAPI.getPaths(); + List visitedSchemas = new ArrayList<>(); + + if (paths != null) { + for (PathItem path : paths.values()) { + visitPathItem(path, openAPI, visitor, visitedSchemas); + } + } + } + + private static void visitPathItem(PathItem pathItem, OpenAPI openAPI, OpenAPISchemaVisitor visitor, List visitedSchemas) { + List allOperations = pathItem.readOperations(); + if (allOperations != null) { + for (Operation operation : allOperations) { + //Params: + visitParameters(openAPI, operation.getParameters(), visitor, visitedSchemas); + + //RequestBody: + RequestBody requestBody = getReferencedRequestBody(openAPI, operation.getRequestBody()); + if (requestBody != null) { + visitContent(openAPI, requestBody.getContent(), visitor, visitedSchemas); + } + + //Responses: + if (operation.getResponses() != null) { + for (ApiResponse r : operation.getResponses().values()) { + ApiResponse apiResponse = getReferencedApiResponse(openAPI, r); + if (apiResponse != null) { + visitContent(openAPI, apiResponse.getContent(), visitor, visitedSchemas); + if (apiResponse.getHeaders() != null) { + for (Map.Entry e : apiResponse.getHeaders().entrySet()) { + Header header = getReferencedHeader(openAPI, e.getValue()); + if (header.getSchema() != null) { + visitSchema(openAPI, header.getSchema(), e.getKey(), visitedSchemas, visitor); + } + visitContent(openAPI, header.getContent(), visitor, visitedSchemas); + } + } + } + } + } + + //Callbacks: + if (operation.getCallbacks() != null) { + for (Callback c : operation.getCallbacks().values()) { + Callback callback = getReferencedCallback(openAPI, c); + if (callback != null) { + for (PathItem p : callback.values()) { + visitPathItem(p, openAPI, visitor, visitedSchemas); + } + } + } + } + } + } + //Params: + visitParameters(openAPI, pathItem.getParameters(), visitor, visitedSchemas); + } + + private static void visitParameters(OpenAPI openAPI, List parameters, OpenAPISchemaVisitor visitor, + List visitedSchemas) { + if (parameters != null) { + for (Parameter p : parameters) { + Parameter parameter = getReferencedParameter(openAPI, p); + if (parameter != null) { + if (parameter.getSchema() != null) { + visitSchema(openAPI, parameter.getSchema(), null, visitedSchemas, visitor); + } + visitContent(openAPI, parameter.getContent(), visitor, visitedSchemas); + } else { + once(LOGGER).warn("Unreferenced parameter(s) found."); + } + } + } + } + + private static void visitContent(OpenAPI openAPI, Content content, OpenAPISchemaVisitor visitor, List visitedSchemas) { + if (content != null) { + for (Map.Entry e : content.entrySet()) { + if (e.getValue().getSchema() != null) { + visitSchema(openAPI, e.getValue().getSchema(), e.getKey(), visitedSchemas, visitor); + } + } + } + } + + /** + * Invoke the specified visitor function for every schema that matches mimeType in the OpenAPI document. + *

+ * To avoid infinite recursion, referenced schemas are visited only once. When a referenced schema is visited, + * it is added to visitedSchemas. + * + * @param openAPI the OpenAPI document that contains schema objects. + * @param schema the root schema object to be visited. + * @param mimeType the mime type. TODO: does not seem to be used in a meaningful way. + * @param visitedSchemas the list of referenced schemas that have been visited. + * @param visitor the visitor function which is invoked for every visited schema. + */ + private static void visitSchema(OpenAPI openAPI, Schema schema, String mimeType, List visitedSchemas, OpenAPISchemaVisitor visitor) { + if (schema == null) { + return; + } + + visitor.visit(schema, mimeType); + if (schema.get$ref() != null) { + String ref = getSimpleRef(schema.get$ref()); + if (!visitedSchemas.contains(ref)) { + visitedSchemas.add(ref); + Schema referencedSchema = getSchemas(openAPI).get(ref); + if (referencedSchema != null) { + visitSchema(openAPI, referencedSchema, mimeType, visitedSchemas, visitor); + } + } + } + if (isComposedSchema(schema)) { + List oneOf = schema.getOneOf(); + if (oneOf != null) { + for (Schema s : oneOf) { + visitSchema(openAPI, s, mimeType, visitedSchemas, visitor); + } + } + List allOf = schema.getAllOf(); + if (allOf != null) { + for (Schema s : allOf) { + visitSchema(openAPI, s, mimeType, visitedSchemas, visitor); + } + } + List anyOf = schema.getAnyOf(); + if (anyOf != null) { + for (Schema s : anyOf) { + visitSchema(openAPI, s, mimeType, visitedSchemas, visitor); + } + } + } else if (ModelUtils.isArraySchema(schema)) { + Schema itemsSchema = ModelUtils.getSchemaItems(schema); + if (itemsSchema != null) { + visitSchema(openAPI, itemsSchema, mimeType, visitedSchemas, visitor); + } + } else if (isMapSchema(schema)) { + Object additionalProperties = schema.getAdditionalProperties(); + if (additionalProperties instanceof Schema) { + visitSchema(openAPI, (Schema) additionalProperties, mimeType, visitedSchemas, visitor); + } + } + if (schema.getNot() != null) { + visitSchema(openAPI, schema.getNot(), mimeType, visitedSchemas, visitor); + } + Map properties = schema.getProperties(); + if (properties != null) { + for (Schema property : properties.values()) { + visitSchema(openAPI, property, null, visitedSchemas, visitor); + } + } + } + + public static String getSimpleRef(String ref) { + if (ref == null) { + once(LOGGER).warn("Failed to get the schema name: null"); + //throw new RuntimeException("Failed to get the schema: null"); + return null; + } else if (ref.startsWith("#/components/")) { + ref = ref.substring(ref.lastIndexOf("/") + 1); + } else if (ref.startsWith("#/definitions/")) { + ref = ref.substring(ref.lastIndexOf("/") + 1); + } else { + once(LOGGER).warn("Failed to get the schema name: {}", ref); + //throw new RuntimeException("Failed to get the schema: " + ref); + return null; + } + + try { + ref = URLDecoder.decode(ref,String.valueOf(StandardCharsets.UTF_8) ); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + + // see https://tools.ietf.org/html/rfc6901#section-3 + // Because the characters '~' (%x7E) and '/' (%x2F) have special meanings in + // JSON Pointer, '~' needs to be encoded as '~0' and '/' needs to be encoded + // as '~1' when these characters appear in a reference token. + // This reverses that encoding. + ref = ref.replace("~1", "/").replace("~0", "~"); + + return ref; + } + + /** + * Return true if the specified schema is type object + * We can't use isObjectSchema because it requires properties to exist which is not required + * We can't use isMap because it is true for AnyType use cases + * + * @param schema the OAS schema + * @return true if the specified schema is an Object schema. + */ + public static boolean isTypeObjectSchema(Schema schema) { + return SchemaTypeUtil.OBJECT_TYPE.equals(getType(schema)); + } + + /** + * Return true if the specified schema is an object with a fixed number of properties. + *

+ * A ObjectSchema differs from a MapSchema in the following way: + * - An ObjectSchema is not extensible, i.e. it has a fixed number of properties. + * - A MapSchema is an object that can be extended with an arbitrary set of properties. + * The payload may include dynamic properties. + *

+ * For example, an OpenAPI schema is considered an ObjectSchema in the following scenarios: + *

+ *

+ * type: object + * additionalProperties: false + * properties: + * name: + * type: string + * address: + * type: string + * + * @param schema the OAS schema + * @return true if the specified schema is an Object schema. + */ + public static boolean isObjectSchema(Schema schema) { + if (schema == null) { + return false; + } + + return (schema instanceof ObjectSchema) || + // must not be a map + (SchemaTypeUtil.OBJECT_TYPE.equals(getType(schema)) && !(ModelUtils.isMapSchema(schema))) || + // must have at least one property + (getType(schema) == null && schema.getProperties() != null && !schema.getProperties().isEmpty()); + } + + /** + * Return true if the specified schema is composed, i.e. if it uses + * 'oneOf', 'anyOf' or 'allOf'. + * + * @param schema the OAS schema + * @return true if the specified schema is a Composed schema. + */ + public static boolean isComposedSchema(Schema schema) { + if (schema == null) { + return false; + } + + // in 3.0, ComposeSchema is used for anyOf/oneOf/allOf + // in 3.1, it's not the case so we need more checks below + if (schema instanceof ComposedSchema) { + return true; + } + + // has oneOf + if (schema.getOneOf() != null) { + return true; + } + + // has anyOf + if (schema.getAnyOf() != null) { + return true; + } + + // has allOf + if (schema.getAllOf() != null) { + return true; + } + + return false; + } + + /** + * Return true if the specified 'schema' is an object that can be extended with additional properties. + * Additional properties means a Schema should support all explicitly defined properties plus any + * undeclared properties. + *

+ * A MapSchema differs from an ObjectSchema in the following way: + * - An ObjectSchema is not extensible, i.e. it has a fixed number of properties. + * - A MapSchema is an object that can be extended with an arbitrary set of properties. + * The payload may include dynamic properties. + *

+ * Note that isMapSchema returns true for a composed schema (allOf, anyOf, oneOf) that also defines + * additionalproperties. + *

+ * For example, an OpenAPI schema is considered a MapSchema in the following scenarios: + *

+ *

+ * type: object + * additionalProperties: true + *

+ * type: object + * additionalProperties: + * type: object + * properties: + * code: + * type: integer + *

+ * allOf: + * - $ref: '#/components/schemas/Class1' + * - $ref: '#/components/schemas/Class2' + * additionalProperties: true + * + * @param schema the OAS schema + * @return true if the specified schema is a Map schema. + */ + public static boolean isMapSchema(Schema schema) { + if (schema == null) { + return false; + } + + // additionalProperties explicitly set to false + if ((schema.getAdditionalProperties() instanceof Boolean && Boolean.FALSE.equals(schema.getAdditionalProperties())) || + (schema.getAdditionalProperties() instanceof Schema && Boolean.FALSE.equals(((Schema) schema.getAdditionalProperties()).getBooleanSchemaValue())) + ) { + return false; + } + + if (schema instanceof JsonSchema) { // 3.1 spec + return ((schema.getAdditionalProperties() instanceof Schema) || + (schema.getAdditionalProperties() instanceof Boolean && (Boolean) schema.getAdditionalProperties())); + } else { // 3.0 or 2.x spec + return (schema instanceof MapSchema) || + (schema.getAdditionalProperties() instanceof Schema) || + (schema.getAdditionalProperties() instanceof Boolean && (Boolean) schema.getAdditionalProperties()); + } + } + + /** + * Return true if the specified schema is an array of items. + * + * @param schema the OAS schema + * @return true if the specified schema is an Array schema. + */ + public static boolean isArraySchema(Schema schema) { + if (schema == null) { + return false; + } + return (schema instanceof ArraySchema) || "array".equals(getType(schema)); + } + + /** + * Return the schema in the array's item. Null if schema is not an array. + * + * @param schema the OAS schema + * @return item schema. + */ + public static Schema getSchemaItems(Schema schema) { + if (!isArraySchema(schema)) { + return null; + } + + Schema items = schema.getItems(); + if (items == null) { + if (schema instanceof JsonSchema) { // 3.1 spec + // set the items to a new schema (any type) + items = new Schema<>(); + schema.setItems(items); + } else { // 3.0 spec, default to string + LOGGER.error("Undefined array inner type for `{}`. Default to String.", schema.getName()); + items = new StringSchema().description("TODO default missing array inner type to string"); + schema.setItems(items); + } + } + return items; + } + + public static boolean isSet(Schema schema) { + return ModelUtils.isArraySchema(schema) && Boolean.TRUE.equals(schema.getUniqueItems()); + } + + + public static boolean isStringSchema(Schema schema) { + return schema instanceof StringSchema || SchemaTypeUtil.STRING_TYPE.equals(getType(schema)); + } + + public static boolean isIntegerSchema(Schema schema) { + return schema instanceof IntegerSchema || SchemaTypeUtil.INTEGER_TYPE.equals(getType(schema)); + } + + public static boolean isShortSchema(Schema schema) { + // format: short (int32) + return SchemaTypeUtil.INTEGER_TYPE.equals(getType(schema)) // type: integer + && SchemaTypeUtil.INTEGER32_FORMAT.equals(schema.getFormat()); + } + + public static boolean isLongSchema(Schema schema) { + // format: long (int64) + return SchemaTypeUtil.INTEGER_TYPE.equals(getType(schema)) // type: integer + && SchemaTypeUtil.INTEGER64_FORMAT.equals(schema.getFormat()); + } + + + public static boolean isBooleanSchema(Schema schema) { + return schema instanceof BooleanSchema || SchemaTypeUtil.BOOLEAN_TYPE.equals(getType(schema)); + } + + public static boolean isNumberSchema(Schema schema) { + return schema instanceof NumberSchema || SchemaTypeUtil.NUMBER_TYPE.equals(getType(schema)); + } + + public static boolean isFloatSchema(Schema schema) { + // format: float + return SchemaTypeUtil.NUMBER_TYPE.equals(getType(schema)) + && SchemaTypeUtil.FLOAT_FORMAT.equals(schema.getFormat()); + } + + public static boolean isDoubleSchema(Schema schema) { + // format: double + return SchemaTypeUtil.NUMBER_TYPE.equals(getType(schema)) + && SchemaTypeUtil.DOUBLE_FORMAT.equals(schema.getFormat()); + } + + public static boolean isDateSchema(Schema schema) { + return (schema instanceof DateSchema) || + // format: date + (SchemaTypeUtil.STRING_TYPE.equals(getType(schema)) + && SchemaTypeUtil.DATE_FORMAT.equals(schema.getFormat())); + } + + public static boolean isDateTimeSchema(Schema schema) { + return (schema instanceof DateTimeSchema) || + // format: date-time + (SchemaTypeUtil.STRING_TYPE.equals(getType(schema)) + && SchemaTypeUtil.DATE_TIME_FORMAT.equals(schema.getFormat())); + } + + public static boolean isPasswordSchema(Schema schema) { + return (schema instanceof PasswordSchema) || + // double + (SchemaTypeUtil.STRING_TYPE.equals(getType(schema)) + && SchemaTypeUtil.PASSWORD_FORMAT.equals(schema.getFormat())); + } + + public static boolean isByteArraySchema(Schema schema) { + return (schema instanceof ByteArraySchema) || + // format: byte + (SchemaTypeUtil.STRING_TYPE.equals(getType(schema)) + && SchemaTypeUtil.BYTE_FORMAT.equals(schema.getFormat())); + } + + public static boolean isBinarySchema(Schema schema) { + return (schema instanceof BinarySchema) || + // format: binary + (SchemaTypeUtil.STRING_TYPE.equals(getType(schema)) + && SchemaTypeUtil.BINARY_FORMAT.equals(schema.getFormat())); + } + + public static boolean isFileSchema(Schema schema) { + return (schema instanceof FileSchema) || + // file type in oas2 mapped to binary in oas3 + isBinarySchema(schema); + } + + public static boolean isUUIDSchema(Schema schema) { + return (schema instanceof UUIDSchema) || + // format: uuid + (SchemaTypeUtil.STRING_TYPE.equals(getType(schema)) + && SchemaTypeUtil.UUID_FORMAT.equals(schema.getFormat())); + } + + public static boolean isURISchema(Schema schema) { + // format: uri + return SchemaTypeUtil.STRING_TYPE.equals(getType(schema)) + && URI_FORMAT.equals(schema.getFormat()); + } + + public static boolean isEnumSchema(final Schema schema) { + // MyEnum: + // type: string + // enum: + // - ENUM_1 + // - ENUM_2 + return schema.getEnum() != null + && !schema.getEnum().isEmpty(); + } + + public static boolean isEmailSchema(Schema schema) { + return (schema instanceof EmailSchema) || + // format: email + (SchemaTypeUtil.STRING_TYPE.equals(getType(schema)) + && SchemaTypeUtil.EMAIL_FORMAT.equals(schema.getFormat())); + } + + public static boolean isDecimalSchema(Schema schema) { + // format: number + return SchemaTypeUtil.STRING_TYPE.equals(getType(schema)) // type: string + && "number".equals(schema.getFormat()); + } + + + + + + /** + * Check to see if the schema is a model with properties only (non-composed model) + * + * @param schema potentially containing a '$ref' + * @return true if it's a model with at least one properties + */ + public static boolean isModelWithPropertiesOnly(Schema schema) { + return (schema != null) && + // has properties + (null != schema.getProperties() && !schema.getProperties().isEmpty()) && + // no additionalProperties is set + (schema.getAdditionalProperties() == null || + // additionalProperties is boolean and set to false + (schema.getAdditionalProperties() instanceof Boolean && !(Boolean) schema.getAdditionalProperties()) || + // additionalProperties is a schema with its boolean value set to false + (schema.getAdditionalProperties() instanceof Schema && + ((Schema) schema.getAdditionalProperties()).getBooleanSchemaValue() != null && + !((Schema) schema.getAdditionalProperties()).getBooleanSchemaValue()) + ); + } + + + /** + * Check to see if the schema is a free form object. + *

+ * A free form object is an object (i.e. 'type: object' in a OAS document) that: + * 1) Does not define properties, and + * 2) Is not a composed schema (no anyOf, oneOf, allOf), and + * 3) additionalproperties is not defined, or additionalproperties: true, or additionalproperties: {}. + *

+ * Examples: + *

+ * components: + * schemas: + * arbitraryObject: + * type: object + * description: This is a free-form object. + * The value must be a map of strings to values. The value cannot be 'null'. + * It cannot be array, string, integer, number. + * arbitraryNullableObject: + * type: object + * description: This is a free-form object. + * The value must be a map of strings to values. The value can be 'null', + * It cannot be array, string, integer, number. + * nullable: true + * arbitraryTypeValue: + * description: This is NOT a free-form object. + * The value can be any type except the 'null' value. + * + * @param schema potentially containing a '$ref' + * @param openAPI document containing the Schema. + * @return true if it's a free-form object + */ + public static boolean isFreeFormObject(Schema schema, OpenAPI openAPI) { + if (schema == null) { + // TODO: Is this message necessary? A null schema is not a free-form object, so the result is correct. + once(LOGGER).error("Schema cannot be null in isFreeFormObject check"); + return false; + } + + if (schema instanceof JsonSchema) { // 3.1 spec + if (isComposedSchema(schema)) { // composed schema, e.g. allOf, oneOf, anyOf + return false; + } + + if (schema.getProperties() != null && !schema.getProperties().isEmpty()) { // has properties + return false; + } + + if (schema.getAdditionalProperties() instanceof Boolean && (Boolean) schema.getAdditionalProperties()) { + return true; + } else if (schema.getAdditionalProperties() instanceof JsonSchema) { + return true; + } else if (schema.getTypes() != null) { + if (schema.getTypes().size() == 1) { // types = [object] + return SchemaTypeUtil.OBJECT_TYPE.equals(schema.getTypes().iterator().next()); + } else { // has more than 1 type, e.g. types = [integer, string] + return false; + } + } + + return false; + } + + // 3.0.x spec or 2.x spec + // not free-form if allOf, anyOf, oneOf is not empty + if (isComposedSchema(schema)) { + List interfaces = ModelUtils.getInterfaces(schema); + if (interfaces != null && !interfaces.isEmpty()) { + return false; + } + } + + // has at least one property + if ("object".equals(getType(schema))) { + // no properties + if ((schema.getProperties() == null || schema.getProperties().isEmpty())) { + Schema addlProps = ModelUtils.getAdditionalProperties(schema); + + if (schema.getExtensions() != null && schema.getExtensions().containsKey(freeFormExplicit)) { + // User has hard-coded vendor extension to handle free-form evaluation. + boolean isFreeFormExplicit = Boolean.parseBoolean(String.valueOf(schema.getExtensions().get(freeFormExplicit))); + if (!isFreeFormExplicit && addlProps != null && addlProps.getProperties() != null && !addlProps.getProperties().isEmpty()) { + once(LOGGER).error(String.format(Locale.ROOT, "Potentially confusing usage of %s within model which defines additional properties", freeFormExplicit)); + } + return isFreeFormExplicit; + } + + // additionalProperties not defined + if (addlProps == null) { + return true; + } else { + addlProps = getReferencedSchema(openAPI, addlProps); + + if (addlProps instanceof ObjectSchema) { + ObjectSchema objSchema = (ObjectSchema) addlProps; + // additionalProperties defined as {} + return objSchema.getProperties() == null || objSchema.getProperties().isEmpty(); + } else if (addlProps instanceof Schema) { + // additionalProperties defined as {} + return addlProps.getType() == null && addlProps.get$ref() == null && (addlProps.getProperties() == null || addlProps.getProperties().isEmpty()); + } + } + } + } + + return false; + } + + + /** + * If a Schema contains a reference to another Schema with '$ref', returns the referenced Schema if it is found or the actual Schema in the other cases. + * + * @param openAPI specification being checked + * @param schema potentially containing a '$ref' + * @return schema without '$ref' + */ + public static Schema getReferencedSchema(OpenAPI openAPI, Schema schema) { + if (schema == null) { + return null; + } + + if (StringUtils.isEmpty(schema.get$ref())) { + return schema; + } + + try { + Schema refSchema = getSchemaFromRefToSchemaWithProperties(openAPI, schema.get$ref()); + if (refSchema != null) { + // it's ref to schema's properties, #/components/schemas/Pet/properties/category for example + return refSchema; + } + } catch (Exception e) { + LOGGER.warn("Failed to parse $ref {}. Please report the issue to openapi-generator GitHub repo.", schema.get$ref()); + } + + // a simple ref, e.g. #/components/schemas/Pet + String name = getSimpleRef(schema.get$ref()); + Schema referencedSchema = getSchema(openAPI, name); + if (referencedSchema != null) { + return referencedSchema; + } + + return schema; + } + + /** + * Get the schema referenced by $ref to schema's properties, e.g. #/components/schemas/Pet/properties/category. + * + * @param openAPI specification being checked + * @param refString schema reference + * @return schema + */ + public static Schema getSchemaFromRefToSchemaWithProperties(OpenAPI openAPI, String refString) { + if (refString == null) { + return null; + } + + String[] parts = refString.split("/"); + // #/components/schemas/Pet/properties/category + if (parts.length == 6 && "properties".equals(parts[4])) { + Schema referencedSchema = getSchema(openAPI, parts[3]); // parts[3] is Pet + return (Schema) referencedSchema.getProperties().get(parts[5]); // parts[5] is category + } else { + return null; + } + } + + /** + * Returns true if $ref to a reference to schema's properties, e.g. #/components/schemas/Pet/properties/category. + * + * @param refString schema reference + * @return true if $ref to a reference to schema's properties + */ + public static boolean isRefToSchemaWithProperties(String refString) { + if (refString == null) { + return false; + } + + String[] parts = refString.split("/"); + // #/components/schemas/Pet/properties/category + if (parts.length == 6 && "properties".equals(parts[4])) { + return true; + } else { + return false; + } + } + + public static Schema getSchema(OpenAPI openAPI, String name) { + if (name == null) { + return null; + } + + return getSchemas(openAPI).get(name); + } + + /** + * Return a Map of the schemas defined under /components/schemas in the OAS document. + * The returned Map only includes the direct children of /components/schemas in the OAS document; the Map + * does not include inlined schemas. + * + * @param openAPI the OpenAPI document. + * @return a map of schemas in the OAS document. + */ + public static Map getSchemas(OpenAPI openAPI) { + if (openAPI != null && openAPI.getComponents() != null && openAPI.getComponents().getSchemas() != null) { + return openAPI.getComponents().getSchemas(); + } + return Collections.emptyMap(); + } + + + /** + * If a RequestBody contains a reference to another RequestBody with '$ref', returns the referenced RequestBody if it is found or the actual RequestBody in the other cases. + * + * @param openAPI specification being checked + * @param requestBody potentially containing a '$ref' + * @return requestBody without '$ref' + */ + public static RequestBody getReferencedRequestBody(OpenAPI openAPI, RequestBody requestBody) { + if (requestBody != null && StringUtils.isNotEmpty(requestBody.get$ref())) { + String name = getSimpleRef(requestBody.get$ref()); + RequestBody referencedRequestBody = getRequestBody(openAPI, name); + if (referencedRequestBody != null) { + return referencedRequestBody; + } + } + return requestBody; + } + + public static RequestBody getRequestBody(OpenAPI openAPI, String name) { + if (name == null) { + return null; + } + + if (openAPI != null && openAPI.getComponents() != null && openAPI.getComponents().getRequestBodies() != null) { + return openAPI.getComponents().getRequestBodies().get(name); + } + return null; + } + + /** + * If a ApiResponse contains a reference to another ApiResponse with '$ref', returns the referenced ApiResponse if it is found or the actual ApiResponse in the other cases. + * + * @param openAPI specification being checked + * @param apiResponse potentially containing a '$ref' + * @return apiResponse without '$ref' + */ + public static ApiResponse getReferencedApiResponse(OpenAPI openAPI, ApiResponse apiResponse) { + if (apiResponse != null && StringUtils.isNotEmpty(apiResponse.get$ref())) { + String name = getSimpleRef(apiResponse.get$ref()); + ApiResponse referencedApiResponse = getApiResponse(openAPI, name); + if (referencedApiResponse != null) { + return referencedApiResponse; + } + } + return apiResponse; + } + + public static ApiResponse getApiResponse(OpenAPI openAPI, String name) { + if (name != null && openAPI != null && openAPI.getComponents() != null && openAPI.getComponents().getResponses() != null) { + return openAPI.getComponents().getResponses().get(name); + } + return null; + } + + /** + * If a Parameter contains a reference to another Parameter with '$ref', returns the referenced Parameter if it is found or the actual Parameter in the other cases. + * + * @param openAPI specification being checked + * @param parameter potentially containing a '$ref' + * @return parameter without '$ref' + */ + public static Parameter getReferencedParameter(OpenAPI openAPI, Parameter parameter) { + if (parameter != null && StringUtils.isNotEmpty(parameter.get$ref())) { + String name = getSimpleRef(parameter.get$ref()); + Parameter referencedParameter = getParameter(openAPI, name); + if (referencedParameter != null) { + return referencedParameter; + } + } + return parameter; + } + + public static Parameter getParameter(OpenAPI openAPI, String name) { + if (name != null && openAPI != null && openAPI.getComponents() != null && openAPI.getComponents().getParameters() != null) { + return openAPI.getComponents().getParameters().get(name); + } + return null; + } + + /** + * If a Callback contains a reference to another Callback with '$ref', returns the referenced Callback if it is found or the actual Callback in the other cases. + * + * @param openAPI specification being checked + * @param callback potentially containing a '$ref' + * @return callback without '$ref' + */ + public static Callback getReferencedCallback(OpenAPI openAPI, Callback callback) { + if (callback != null && StringUtils.isNotEmpty(callback.get$ref())) { + String name = getSimpleRef(callback.get$ref()); + Callback referencedCallback = getCallback(openAPI, name); + if (referencedCallback != null) { + return referencedCallback; + } + } + return callback; + } + + public static Callback getCallback(OpenAPI openAPI, String name) { + if (name != null && openAPI != null && openAPI.getComponents() != null && openAPI.getComponents().getCallbacks() != null) { + return openAPI.getComponents().getCallbacks().get(name); + } + return null; + } + + + + /** + * Has self reference? + * + * @param openAPI OpenAPI spec. + * @param schema Schema + * @param visitedSchemaNames A set of visited schema names + * @return boolean true if it has at least one self reference + */ + public static boolean hasSelfReference(OpenAPI openAPI, + Schema schema, + Set visitedSchemaNames) { + if (visitedSchemaNames == null) { + visitedSchemaNames = new HashSet(); + } + + if (schema.get$ref() != null) { + String ref = getSimpleRef(schema.get$ref()); + if (!visitedSchemaNames.contains(ref)) { + visitedSchemaNames.add(ref); + Schema referencedSchema = getSchemas(openAPI).get(ref); + if (referencedSchema != null) { + return hasSelfReference(openAPI, referencedSchema, visitedSchemaNames); + } else { + LOGGER.error("Failed to obtain schema from `{}` in self reference check", ref); + return false; + } + } else { + return true; + } + } + if (isComposedSchema(schema)) { + List oneOf = schema.getOneOf(); + if (oneOf != null) { + for (Schema s : oneOf) { + if (hasSelfReference(openAPI, s, visitedSchemaNames)) { + return true; + } + } + } + List allOf = schema.getAllOf(); + if (allOf != null) { + for (Schema s : allOf) { + if (hasSelfReference(openAPI, s, visitedSchemaNames)) { + return true; + } + } + } + List anyOf = schema.getAnyOf(); + if (anyOf != null) { + for (Schema s : anyOf) { + if (hasSelfReference(openAPI, s, visitedSchemaNames)) { + return true; + } + } + } + } else if (isArraySchema(schema)) { + Schema itemsSchema = ModelUtils.getSchemaItems(schema); + if (itemsSchema != null) { + return hasSelfReference(openAPI, itemsSchema, visitedSchemaNames); + } + } else if (isMapSchema(schema)) { + Object additionalProperties = schema.getAdditionalProperties(); + if (additionalProperties instanceof Schema) { + return hasSelfReference(openAPI, (Schema) additionalProperties, visitedSchemaNames); + } + } else if (schema.getNot() != null) { + return hasSelfReference(openAPI, schema.getNot(), visitedSchemaNames); + } else if (schema.getProperties() != null && !schema.getProperties().isEmpty()) { + // go through properties to see if there's any self-reference + for (Schema property : ((Map) schema.getProperties()).values()) { + if (hasSelfReference(openAPI, property, visitedSchemaNames)) { + return true; + } + } + } + return false; + } + + + /** + * Get the actual schema from aliases. If the provided schema is not an alias, the schema itself will be returned. + * + * @param openAPI OpenAPI document containing the schemas. + * @param schema schema (alias or direct reference) + * @param schemaMappings mappings of external types to be omitted by unaliasing + * @return actual schema + */ + public static Schema unaliasSchema(OpenAPI openAPI, + Schema schema, + Map schemaMappings) { + Map allSchemas = getSchemas(openAPI); + if (allSchemas == null || allSchemas.isEmpty()) { + // skip the warning as the spec can have no model defined + //LOGGER.warn("allSchemas cannot be null/empty in unaliasSchema. Returned 'schema'"); + return schema; + } + + if (schema != null && StringUtils.isNotEmpty(schema.get$ref())) { + String simpleRef = ModelUtils.getSimpleRef(schema.get$ref()); + if (schemaMappings.containsKey(simpleRef)) { + LOGGER.debug("Schema unaliasing of {} omitted because aliased class is to be mapped to {}", simpleRef, schemaMappings.get(simpleRef)); + return schema; + } + Schema ref = allSchemas.get(simpleRef); + if (ref == null) { + if (!isRefToSchemaWithProperties(schema.get$ref())) { + once(LOGGER).warn("{} is not defined", schema.get$ref()); + } + return schema; + } else if (isEnumSchema(ref)) { + // top-level enum class + return schema; + } else if (isArraySchema(ref)) { + if (isGenerateAliasAsModel(ref)) { + return schema; // generate a model extending array + } else { + return unaliasSchema(openAPI, allSchemas.get(ModelUtils.getSimpleRef(schema.get$ref())), + schemaMappings); + } + } else if (isComposedSchema(ref)) { + return schema; + } else if (isMapSchema(ref)) { + if (ref.getProperties() != null && !ref.getProperties().isEmpty()) // has at least one property + return schema; // treat it as model + else { + if (isGenerateAliasAsModel(ref)) { + return schema; // generate a model extending map + } else { + // treat it as a typical map + return unaliasSchema(openAPI, allSchemas.get(ModelUtils.getSimpleRef(schema.get$ref())), + schemaMappings); + } + } + } else if (isObjectSchema(ref)) { // model + if (ref.getProperties() != null && !ref.getProperties().isEmpty()) { // has at least one property + // TODO we may need to check `hasSelfReference(openAPI, ref)` as a special/edge case: + // TODO we may also need to revise below to return `ref` instead of schema + // which is the last reference to the actual model/object + return schema; + } else { // free form object (type: object) + return unaliasSchema(openAPI, allSchemas.get(ModelUtils.getSimpleRef(schema.get$ref())), + schemaMappings); + } + } else { + return unaliasSchema(openAPI, allSchemas.get(ModelUtils.getSimpleRef(schema.get$ref())), schemaMappings); + } + } + return schema; + } + + /** + * Returns the additionalProperties Schema for the specified input schema. + *

+ * The additionalProperties keyword is used to control the handling of additional, undeclared + * properties, that is, properties whose names are not listed in the properties keyword. + * The additionalProperties keyword may be either a boolean or an object. + * If additionalProperties is a boolean and set to false, no additional properties are allowed. + * By default when the additionalProperties keyword is not specified in the input schema, + * any additional properties are allowed. This is equivalent to setting additionalProperties + * to the boolean value True or setting additionalProperties: {} + * + * @param schema the input schema that may or may not have the additionalProperties keyword. + * @return the Schema of the additionalProperties. The null value is returned if no additional + * properties are allowed. + */ + public static Schema getAdditionalProperties(Schema schema) { + Object addProps = schema.getAdditionalProperties(); + if (addProps instanceof Schema) { + return (Schema) addProps; + } + if (addProps == null) { + // When reaching this code path, this should indicate the 'additionalProperties' keyword is + // not present in the OAS schema. This is true for OAS 3.0 documents. + // However, the parsing logic is broken for OAS 2.0 documents because of the + // https://github.com/swagger-api/swagger-parser/issues/1369 issue. + // When OAS 2.0 documents are parsed, the swagger-v2-converter ignores the 'additionalProperties' + // keyword if the value is boolean. That means codegen is unable to determine whether + // additional properties are allowed or not. + // + // The original behavior was to assume additionalProperties had been set to false. + if (isDisallowAdditionalPropertiesIfNotPresent()) { + // If the 'additionalProperties' keyword is not present in a OAS schema, + // interpret as if the 'additionalProperties' keyword had been set to false. + // This is NOT compliant with the JSON schema specification. It is the original + // 'openapi-generator' behavior. + return null; + } + /* + // The disallowAdditionalPropertiesIfNotPresent CLI option has been set to true, + // but for now that only works with OAS 3.0 documents. + // The new behavior does not work with OAS 2.0 documents. + if (extensions == null || !extensions.containsKey(EXTENSION_OPENAPI_DOC_VERSION)) { + // Fallback to the legacy behavior. + return null; + } + // Get original swagger version from OAS extension. + // Note openAPI.getOpenapi() is always set to 3.x even when the document + // is converted from a OAS/Swagger 2.0 document. + // https://github.com/swagger-api/swagger-parser/pull/1374 + SemVer version = new SemVer((String)extensions.get(EXTENSION_OPENAPI_DOC_VERSION)); + if (version.major != 3) { + return null; + } + */ + } + if (addProps == null || (addProps instanceof Boolean && (Boolean) addProps)) { + // Return an empty schema as the properties can take on any type per + // the spec. See + // https://github.com/OpenAPITools/openapi-generator/issues/9282 for + // more details. + return new Schema(); + } + return null; + } + + public static Header getReferencedHeader(OpenAPI openAPI, Header header) { + if (header != null && StringUtils.isNotEmpty(header.get$ref())) { + String name = getSimpleRef(header.get$ref()); + Header referencedheader = getHeader(openAPI, name); + if (referencedheader != null) { + return referencedheader; + } + } + return header; + } + + public static Header getHeader(OpenAPI openAPI, String name) { + if (name != null && openAPI != null && openAPI.getComponents() != null && openAPI.getComponents().getHeaders() != null) { + return openAPI.getComponents().getHeaders().get(name); + } + return null; + } + + public static Map> getChildrenMap(OpenAPI openAPI) { + Map allSchemas = getSchemas(openAPI); + + Map>> groupedByParent = allSchemas.entrySet().stream() + .filter(entry -> isComposedSchema(entry.getValue())) + .filter(entry -> getParentName((Schema) entry.getValue(), allSchemas) != null) + .collect(Collectors.groupingBy(entry -> getParentName((Schema) entry.getValue(), allSchemas))); + + return groupedByParent.entrySet().stream() + .collect(Collectors.toMap(entry -> entry.getKey(), entry -> entry.getValue().stream().map(e -> e.getKey()).collect(Collectors.toList()))); + } + + /** + * Get the interfaces from the schema (composed) + * + * @param composed schema (alias or direct reference) + * @return a list of schema defined in allOf, anyOf or oneOf + */ + public static List getInterfaces(Schema composed) { + if (composed.getAllOf() != null && !composed.getAllOf().isEmpty()) { + return composed.getAllOf(); + } else if (composed.getAnyOf() != null && !composed.getAnyOf().isEmpty()) { + return composed.getAnyOf(); + } else if (composed.getOneOf() != null && !composed.getOneOf().isEmpty()) { + return composed.getOneOf(); + } else { + return Collections.emptyList(); + } + } + + /** + * Get the parent model name from the composed schema (allOf, anyOf, oneOf). + * It traverses the OAS model (possibly resolving $ref) to determine schemas + * that specify a determinator. + * If there are multiple elements in the composed schema and it is not clear + * which one should be the parent, return null. + *

+ * For example, given the following OAS spec, the parent of 'Dog' is Animal + * because 'Animal' specifies a discriminator. + *

+ * animal: + * type: object + * discriminator: + * propertyName: type + * properties: + * type: string + * + *

+ * dog: + * allOf: + * - $ref: '#/components/schemas/animal' + * - type: object + * properties: + * breed: string + * + * @param composedSchema schema (alias or direct reference) + * @param allSchemas all schemas + * @return the name of the parent model + */ + public static String getParentName(Schema composedSchema, Map allSchemas) { + List interfaces = getInterfaces(composedSchema); + int nullSchemaChildrenCount = 0; + boolean hasAmbiguousParents = false; + List refedWithoutDiscriminator = new ArrayList<>(); + + if (interfaces != null && !interfaces.isEmpty()) { + List parentNameCandidates = new ArrayList<>(interfaces.size()); + for (Schema schema : interfaces) { + // get the actual schema + if (StringUtils.isNotEmpty(schema.get$ref())) { + String parentName = getSimpleRef(schema.get$ref()); + Schema s = allSchemas.get(parentName); + if (s == null) { + LOGGER.error("Failed to obtain schema from {}", parentName); + parentNameCandidates.add("UNKNOWN_PARENT_NAME"); + } else if (hasOrInheritsDiscriminator(s, allSchemas, new ArrayList())) { + // discriminator.propertyName is used or x-parent is used + parentNameCandidates.add(parentName); + } else { + // not a parent since discriminator.propertyName or x-parent is not set + hasAmbiguousParents = true; + refedWithoutDiscriminator.add(parentName); + } + } else { + // not a ref, doing nothing, except counting the number of times the 'null' type + // is listed as composed element. + if (ModelUtils.isNullType(schema)) { + // If there are two interfaces, and one of them is the 'null' type, + // then the parent is obvious and there is no need to warn about specifying + // a determinator. + nullSchemaChildrenCount++; + } + } + } + if (parentNameCandidates.size() > 1) { + // unclear which one should be the parent + return null; + } else if (parentNameCandidates.size() == 1) { + return parentNameCandidates.get(0); + } + if (refedWithoutDiscriminator.size() == 1 && nullSchemaChildrenCount == 1) { + // One schema is a $ref and the other is the 'null' type, so the parent is obvious. + // In this particular case there is no need to specify a discriminator. + hasAmbiguousParents = false; + } + } + + return null; + } + + /** + * Get the list of parent model names from the schemas (allOf, anyOf, oneOf). + * + * @param composedSchema schema (alias or direct reference) + * @param allSchemas all schemas + * @param includeAncestors if true, include the indirect ancestors in the return value. If false, return the direct parents. + * @return the name of the parent model + */ + public static List getAllParentsName(Schema composedSchema, Map allSchemas, boolean includeAncestors) { + return getAllParentsName(composedSchema, allSchemas, includeAncestors, new HashSet<>()); + } + + // Use a set of seen names to avoid infinite recursion + private static List getAllParentsName( + Schema composedSchema, Map allSchemas, boolean includeAncestors, Set seenNames) { + List interfaces = getInterfaces(composedSchema); + List names = new ArrayList(); + + if (interfaces != null && !interfaces.isEmpty()) { + for (Schema schema : interfaces) { + // get the actual schema + if (StringUtils.isNotEmpty(schema.get$ref())) { + String parentName = getSimpleRef(schema.get$ref()); + if (seenNames.contains(parentName)) { + continue; + } + seenNames.add(parentName); + Schema s = allSchemas.get(parentName); + if (s == null) { + LOGGER.error("Failed to obtain schema from {}", parentName); + names.add("UNKNOWN_PARENT_NAME"); + } else if (hasOrInheritsDiscriminator(s, allSchemas, new ArrayList())) { + // discriminator.propertyName is used or x-parent is used + names.add(parentName); + if (includeAncestors && isComposedSchema(s)) { + names.addAll(getAllParentsName(s, allSchemas, true, seenNames)); + } + } else { + // not a parent since discriminator.propertyName is not set + } + } else { + // not a ref, doing nothing + } + } + } + + // ensure `allParents` always includes `parent` + // this is more robust than keeping logic in getParentName() and getAllParentsName() in sync + String parentName = getParentName(composedSchema, allSchemas); + if (parentName != null && !names.contains(parentName)) { + names.add(parentName); + } + + return names; + } + + private static boolean hasOrInheritsDiscriminator(Schema schema, Map allSchemas, ArrayList visitedSchemas) { + for (Schema s : visitedSchemas) { + if (s == schema) { + return false; + } + } + visitedSchemas.add(schema); + + if ((schema.getDiscriminator() != null && StringUtils.isNotEmpty(schema.getDiscriminator().getPropertyName())) + || (isExtensionParent(schema))) { // x-parent is used + return true; + } else if (StringUtils.isNotEmpty(schema.get$ref())) { + String parentName = getSimpleRef(schema.get$ref()); + Schema s = allSchemas.get(parentName); + if (s != null) { + return hasOrInheritsDiscriminator(s, allSchemas, visitedSchemas); + } else { + LOGGER.error("Failed to obtain schema from {}", parentName); + } + } else if (isComposedSchema(schema)) { + final List interfaces = getInterfaces(schema); + for (Schema i : interfaces) { + if (hasOrInheritsDiscriminator(i, allSchemas, visitedSchemas)) { + return true; + } + } + } + return false; + } + + /** + * If it's a boolean, returns the value of the extension `x-parent`. + * If it's string, return true if it's non-empty. + * If the return value is `true`, the schema is a parent. + * + * @param schema Schema + * @return boolean + */ + public static boolean isExtensionParent(Schema schema) { + if (schema == null || schema.getExtensions() == null) { + return false; + } + + Object xParent = schema.getExtensions().get("x-parent"); + if (xParent == null) { + return false; + } else if (xParent instanceof Boolean) { + return (Boolean) xParent; + } else if (xParent instanceof String) { + return StringUtils.isNotEmpty((String) xParent); + } else { + return false; + } + } + + /** + * isNullType returns true if the input schema is the 'null' type. + *

+ * The 'null' type is supported in OAS 3.1 and above. It is not supported + * in OAS 2.0 and OAS 3.0.x. + *

+ * For example, the "null" type could be used to specify that a value must + * either be null or a specified type: + *

+ * OptionalOrder: + * oneOf: + * - type: 'null' + * - $ref: '#/components/schemas/Order' + * + * @param schema the OpenAPI schema + * @return true if the schema is the 'null' type + */ + public static boolean isNullType(Schema schema) { + return "null".equals(getType(schema)); + } + + /** + * For when a type is not defined on a schema + * Note: properties, additionalProperties, enums, validations, items, and composed schemas (oneOf/anyOf/allOf) + * can be defined or omitted on these any type schemas + * + * @param schema the schema that we are checking + * @return boolean + */ + public static boolean isAnyType(Schema schema) { + // $ref is not a type, it is a keyword + // TODO remove the ref check here, or pass in the spec version + // openapi 3.1.0 specs allow ref to be adjacent to any keyword + // openapi 3.0.3 and earlier do not allow adjacent keywords to refs + return (schema.get$ref() == null && getType(schema) == null); + } + + /** + * Returns true if the schema contains oneOf but + * no properties/allOf/anyOf defined. + * + * @param schema the schema + * @return true if the schema contains oneOf but no properties/allOf/anyOf defined. + */ + public static boolean isOneOf(Schema schema) { + if (schema == null) { + return false; + } + + if (hasOneOf(schema) && (schema.getProperties() == null || schema.getProperties().isEmpty()) && + (schema.getAllOf() == null || schema.getAllOf().isEmpty()) && + (schema.getAnyOf() == null || schema.getAnyOf().isEmpty())) { + return true; + } + + return false; + } + + /** + * Returns true if the schema contains oneOf and may or may not have + * properties/allOf/anyOf defined. + * + * @param schema the schema + * @return true if allOf is not empty + */ + public static boolean hasOneOf(Schema schema) { + if (schema != null && schema.getOneOf() != null && !schema.getOneOf().isEmpty()) { + return true; + } + + return false; + } + + /** + * Returns true if the schema contains anyOf but + * no properties/allOf/anyOf defined. + * + * @param schema the schema + * @return true if the schema contains oneOf but no properties/allOf/anyOf defined. + */ + public static boolean isAnyOf(Schema schema) { + if (schema == null) { + return false; + } + + if (hasAnyOf(schema) && (schema.getProperties() == null || schema.getProperties().isEmpty()) && + (schema.getAllOf() == null || schema.getAllOf().isEmpty()) && + (schema.getOneOf() == null || schema.getOneOf().isEmpty())) { + return true; + } + + return false; + } + + /** + * Returns true if the schema contains anyOf and may or may not have + * properties/allOf/oneOf defined. + * + * @param schema the schema + * @return true if anyOf is not empty + */ + public static boolean hasAnyOf(Schema schema) { + if (schema != null && schema.getAnyOf() != null && !schema.getAnyOf().isEmpty()) { + return true; + } + + return false; + } + + /** + * Returns schema type. + * For 3.1 spec, return the first one. + * + * @param schema the schema + * @return schema type + */ + public static String getType(Schema schema) { + if (schema == null) { + return null; + } + + if (schema instanceof JsonSchema) { + if (schema.getTypes() != null && !schema.getTypes().isEmpty()) { + return String.valueOf(schema.getTypes().iterator().next()); + } else { + return null; + } + } else { + return schema.getType(); + } + } + + + @FunctionalInterface + private interface OpenAPISchemaVisitor { + + void visit(Schema schema, String mimeType); + } } diff --git a/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/utils/OnceLogger.java b/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/utils/OnceLogger.java new file mode 100644 index 00000000000..ea4e707800e --- /dev/null +++ b/modules/swagger-codegen/src/main/java/io/swagger/codegen/v3/utils/OnceLogger.java @@ -0,0 +1,172 @@ +package io.swagger.codegen.v3.utils; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Ticker; +import org.slf4j.Logger; +import org.slf4j.Marker; +import org.slf4j.MarkerFactory; +import org.slf4j.ext.LoggerWrapper; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Provides calling code a way to log important messages only once, regardless of how many times the invocation has occurred. + * This can be used, for instance, to log a warning like "One or more schemas aren't declared" without logging that message + * for every time the schema is mentioned in a document. + *

+ * This implementation currently only supports single-argument string literal log methods (e.g. {@link Logger#debug(String)}). + */ +@SuppressWarnings("FieldCanBeLocal") +public class OnceLogger extends LoggerWrapper { + /** + * Allow advanced users to modify cache size of the OnceLogger (more for performance tuning in hosted environments) + */ + static final String CACHE_SIZE_PROPERTY = "org.openapitools.codegen.utils.oncelogger.cachesize"; + + /** + * Allow advanced users to disable the OnceLogger (more for performance tuning in hosted environments). + * This is really only useful or necessary if this implementation causes issues. + */ + static final String ENABLE_ONCE_LOGGER_PROPERTY = "org.openapitools.codegen.utils.oncelogger.enabled"; + + /** + * Allow advanced users to modify cache expiration of the OnceLogger (more for performance tuning in hosted environments) + */ + static final String EXPIRY_PROPERTY = "org.openapitools.codegen.utils.oncelogger.expiry"; + + /** + * Internal message cache for logger decorated with the onceler. + */ + static Cache messageCountCache; + + /** + * The fully qualified class name of the logger instance, + * typically the logger class, logger bridge or a logger wrapper. + */ + private static final String FQCN = OnceLogger.class.getName(); + + /** + * Gets the marker instance. This can be used by supported log implementations to filter/manage logs coming from + * this implementation differently than others (i.e. make them stand out since they're to be logged once). + */ + private static final Marker MARKER = MarkerFactory.getMarker("ONCE"); + + /** + * The allowed size of the cache. + */ + private static int maxCacheSize = Integer.parseInt(GlobalSettings.getProperty(CACHE_SIZE_PROPERTY, "200")); + + /** + * The millis to expire a cached log message. + */ + private static int expireMillis = Integer.parseInt(GlobalSettings.getProperty(EXPIRY_PROPERTY, "2000")); + + /** + * The number of allowed repetitions. + */ + private static int maxRepetitions = 1; + + OnceLogger(Logger logger) { + this(logger, FQCN); + } + + OnceLogger(Logger logger, String fqcn) { + super(logger, fqcn); + } + + static { + caffeineCache(Ticker.systemTicker(), expireMillis); + } + + static void caffeineCache(Ticker ticker, int expireMillis) { + // Initializes a cache which holds an atomic counter of log message instances. + // The intent is to debounce log messages such that they occur at most [maxRepetitions] per [expireMillis]. + messageCountCache = Caffeine.newBuilder() + .maximumSize(maxCacheSize) + .expireAfterWrite(expireMillis, TimeUnit.MILLISECONDS) + .ticker(ticker) + .build(); + } + + public static Logger once(Logger logger) { + try { + if (Boolean.parseBoolean(GlobalSettings.getProperty(ENABLE_ONCE_LOGGER_PROPERTY, "true"))) { + return new OnceLogger(logger); + } + } catch (Exception ex) { + logger.warn("Unable to wrap logger instance in OnceLogger. Falling back to non-decorated implementation, which may be noisy."); + } + return logger; + } + + /** + * Delegate to the appropriate method of the underlying logger. + * + * @param msg The log message. + */ + @Override + public void trace(String msg) { + if (!isTraceEnabled() || !isTraceEnabled(MARKER)) return; + + if (shouldLog(msg)) super.trace(MARKER, msg); + } + + @SuppressWarnings("ConstantConditions") + private boolean shouldLog(final String msg) { + AtomicInteger counter = messageCountCache.get(msg, i -> new AtomicInteger(0)); + return counter.incrementAndGet() <= maxRepetitions; + } + + /** + * Delegate to the appropriate method of the underlying logger. + * + * @param msg The log message. + */ + @Override + public void debug(String msg) { + if (!isDebugEnabled() || !isDebugEnabled(MARKER)) return; + + if (shouldLog(msg)) super.debug(MARKER, msg); + } + + /** + * Delegate to the appropriate method of the underlying logger. + * + * @param msg The log message. + */ + @Override + public void info(String msg) { + if (!isInfoEnabled() || !isInfoEnabled(MARKER)) return; + + if (shouldLog(msg)) super.info(MARKER, msg); + } + + /** + * Delegate to the appropriate method of the underlying logger. + * + * @param msg The log message. + */ + @Override + public void warn(String msg) { + if (!isWarnEnabled() || !isWarnEnabled(MARKER)) return; + + if (shouldLog(msg)) super.warn(MARKER, msg); + } + + /** + * Delegate to the appropriate method of the underlying logger. + *

+ * Use this method sparingly. If you're limiting error messages, ask yourself + * whether your log fits better as a warning. + * + * @param msg The log message. + */ + @Override + public void error(String msg) { + if (!isErrorEnabled() || !isErrorEnabled(MARKER)) return; + + if (shouldLog(msg)) super.error(MARKER, msg); + } +}