Add OpenAPI Normalizer (#14172)

* add x-parent support

* add docstring

* add openapi normalizer rule to use ref as parent in allof

* add openapi normalizer with 1 rule

* revise wordings

* fix javadoc warnings

* better test

* fix docstring

* minor update

* minor improvements

* fix typo
This commit is contained in:
William Cheng 2022-12-30 16:03:21 +08:00 committed by GitHub
parent 3a8265b6ee
commit b71aecbe9e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 771 additions and 46 deletions

View File

@ -451,3 +451,16 @@ Another useful option is `inlineSchemaNameDefaults`, which allows you to customi
```
Note: Only arrayItemSuffix, mapItemSuffix are supported at the moment. `SKIP_SCHEMA_REUSE=true` is a special value to skip reusing inline schemas.
## OpenAPI Normalizer
OpenAPI Normalizer (off by default) transforms the input OpenAPI doc/spec (which may not perfectly conform to the specification) to make it workable with OpenAPI Generator. Here is a list of rules supported:
- `REF_AS_PARENT_IN_ALLOF`: when set to `true`, child schemas in `allOf` is considered a parent if it's a `$ref` (instead of inline schema)
Example:
```
java -jar modules/openapi-generator-cli/target/openapi-generator-cli.jar generate -g java -i modules/openapi-generator/src/test/resources/3_0/allOf_extension_parent.yaml -o /tmp/java-okhttp/ --additional-properties hideGenerationTimestamp="true" --openapi-normalizer REF_AS_PARENT_IN_ALLOF=true
```

View File

@ -80,6 +80,9 @@ public class ConfigHelp extends OpenApiGeneratorCommand {
@Option(name = {"--inline-schema-name-defaults"}, title = "inline schema name defaults", description = "default values used when naming inline schema name")
private Boolean inlineSchemaNameDefaults;
@Option(name = {"--openapi-normalizer"}, title = "openapi normalizer rules", description = "displays the OpenAPI normalizer rules (none)")
private Boolean openapiNormalizer;
@Option(name = {"--metadata"}, title = "metadata", description = "displays the generator metadata like the help txt for the generator and generator type etc")
private Boolean metadata;
@ -494,6 +497,18 @@ public class ConfigHelp extends OpenApiGeneratorCommand {
sb.append(newline);
}
if (Boolean.TRUE.equals(openapiNormalizer)) {
sb.append(newline).append("OPENAPI NORMALIZER RULES").append(newline).append(newline);
Map<String, String> map = config.openapiNormalizer()
.entrySet()
.stream()
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> {
throw new IllegalStateException(String.format(Locale.ROOT, "Duplicated options! %s and %s", a, b));
}, TreeMap::new));
writePlainTextFromMap(sb, map, optIndent, optNestedIndent, "OpenAPI normalizer rule", "Set to");
sb.append(newline);
}
if (Boolean.TRUE.equals(instantiationTypes)) {
sb.append(newline).append("INSTANTIATION TYPES").append(newline).append(newline);
Map<String, String> map = config.instantiationTypes()

View File

@ -180,6 +180,13 @@ public class Generate extends OpenApiGeneratorCommand {
+ " ONLY arrayItemSuffix, mapItemSuffix are supported at the moment. `SKIP_SCHEMA_REUSE=true` is a special value to skip reusing inline schemas.")
private List<String> inlineSchemaNameDefaults = new ArrayList<>();
@Option(
name = {"--openapi-normalizer"},
title = "OpenAPI normalizer rules",
description = "specifies the rules to be enabled in OpenAPI normalizer in the form of RULE_1=true,RULE_2=original."
+ " You can also have multiple occurrences of this option.")
private List<String> openapiNormalizer = new ArrayList<>();
@Option(
name = {"--server-variables"},
title = "server variables",
@ -447,6 +454,7 @@ public class Generate extends OpenApiGeneratorCommand {
applySchemaMappingsKvpList(schemaMappings, configurator);
applyInlineSchemaNameMappingsKvpList(inlineSchemaNameMappings, configurator);
applyInlineSchemaNameDefaultsKvpList(inlineSchemaNameDefaults, configurator);
applyOpenAPINormalizerKvpList(openapiNormalizer, configurator);
applyTypeMappingsKvpList(typeMappings, configurator);
applyAdditionalPropertiesKvpList(additionalProperties, configurator);
applyLanguageSpecificPrimitivesCsvList(languageSpecificPrimitives, configurator);

View File

@ -53,6 +53,7 @@ public final class GeneratorSettings implements Serializable {
private final Map<String, String> schemaMappings;
private final Map<String, String> inlineSchemaNameMappings;
private final Map<String, String> inlineSchemaNameDefaults;
private final Map<String, String> openapiNormalizer;
private final Set<String> languageSpecificPrimitives;
private final Map<String, String> reservedWordsMappings;
private final Map<String, String> serverVariables;
@ -264,6 +265,15 @@ public final class GeneratorSettings implements Serializable {
return inlineSchemaNameDefaults;
}
/**
* Gets OpenAPI normalizer rules
*
* @return a map of rules
*/
public Map<String, String> getOpenAPINormalizer() {
return openapiNormalizer;
}
/**
* Gets language specific primitives. These are in addition to the "base" primitives defined in a generator.
* <p>
@ -382,6 +392,7 @@ public final class GeneratorSettings implements Serializable {
schemaMappings = Collections.unmodifiableMap(builder.schemaMappings);
inlineSchemaNameMappings = Collections.unmodifiableMap(builder.inlineSchemaNameMappings);
inlineSchemaNameDefaults = Collections.unmodifiableMap(builder.inlineSchemaNameDefaults);
openapiNormalizer = Collections.unmodifiableMap(builder.openapiNormalizer);
languageSpecificPrimitives = Collections.unmodifiableSet(builder.languageSpecificPrimitives);
reservedWordsMappings = Collections.unmodifiableMap(builder.reservedWordsMappings);
serverVariables = Collections.unmodifiableMap(builder.serverVariables);
@ -455,6 +466,7 @@ public final class GeneratorSettings implements Serializable {
schemaMappings = Collections.unmodifiableMap(new HashMap<>(0));
inlineSchemaNameMappings = Collections.unmodifiableMap(new HashMap<>(0));
inlineSchemaNameDefaults = Collections.unmodifiableMap(new HashMap<>(0));
openapiNormalizer = Collections.unmodifiableMap(new HashMap<>(0));
languageSpecificPrimitives = Collections.unmodifiableSet(new HashSet<>(0));
reservedWordsMappings = Collections.unmodifiableMap(new HashMap<>(0));
serverVariables = Collections.unmodifiableMap(new HashMap<>(0));
@ -515,6 +527,9 @@ public final class GeneratorSettings implements Serializable {
if (copy.getInlineSchemaNameDefaults() != null) {
builder.inlineSchemaNameDefaults.putAll(copy.getInlineSchemaNameDefaults());
}
if (copy.getOpenAPINormalizer() != null) {
builder.openapiNormalizer.putAll(copy.getOpenAPINormalizer());
}
if (copy.getLanguageSpecificPrimitives() != null) {
builder.languageSpecificPrimitives.addAll(copy.getLanguageSpecificPrimitives());
}
@ -557,6 +572,7 @@ public final class GeneratorSettings implements Serializable {
private Map<String, String> schemaMappings;
private Map<String, String> inlineSchemaNameMappings;
private Map<String, String> inlineSchemaNameDefaults;
private Map<String, String> openapiNormalizer;
private Set<String> languageSpecificPrimitives;
private Map<String, String> reservedWordsMappings;
private Map<String, String> serverVariables;
@ -577,6 +593,7 @@ public final class GeneratorSettings implements Serializable {
schemaMappings = new HashMap<>();
inlineSchemaNameMappings = new HashMap<>();
inlineSchemaNameDefaults = new HashMap<>();
openapiNormalizer = new HashMap<>();
languageSpecificPrimitives = new HashSet<>();
reservedWordsMappings = new HashMap<>();
serverVariables = new HashMap<>();
@ -897,6 +914,32 @@ public final class GeneratorSettings implements Serializable {
return this;
}
/**
* Sets the {@code openapiNormalizer} and returns a reference to this Builder so that the methods can be chained together.
*
* @param openapiNormalizer the {@code openapiNormalizer} to set
* @return a reference to this Builder
*/
public Builder withOpenAPINormalizer(Map<String, String> openapiNormalizer) {
this.openapiNormalizer = openapiNormalizer;
return this;
}
/**
* Sets a single {@code openapiNormalizer} and returns a reference to this Builder so that the methods can be chained together.
*
* @param key A key for the OpenAPI normalizer rule
* @param value The value of the OpenAPI normalizer rule
* @return a reference to this Builder
*/
public Builder withOpenAPINormalizer(String key, String value) {
if (this.openapiNormalizer == null) {
this.openapiNormalizer = new HashMap<>();
}
this.openapiNormalizer.put(key, value);
return this;
}
/**
* Sets the {@code languageSpecificPrimitives} and returns a reference to this Builder so that the methods can be chained together.
*
@ -1085,6 +1128,7 @@ public final class GeneratorSettings implements Serializable {
Objects.equals(getSchemaMappings(), that.getSchemaMappings()) &&
Objects.equals(getInlineSchemaNameMappings(), that.getInlineSchemaNameMappings()) &&
Objects.equals(getInlineSchemaNameDefaults(), that.getInlineSchemaNameDefaults()) &&
Objects.equals(getOpenAPINormalizer(), that.getOpenAPINormalizer()) &&
Objects.equals(getLanguageSpecificPrimitives(), that.getLanguageSpecificPrimitives()) &&
Objects.equals(getReservedWordsMappings(), that.getReservedWordsMappings()) &&
Objects.equals(getGitHost(), that.getGitHost()) &&
@ -1116,6 +1160,7 @@ public final class GeneratorSettings implements Serializable {
getSchemaMappings(),
getInlineSchemaNameMappings(),
getInlineSchemaNameDefaults(),
getOpenAPINormalizer(),
getLanguageSpecificPrimitives(),
getReservedWordsMappings(),
getGitHost(),

View File

@ -118,6 +118,7 @@ class OpenApiGeneratorPlugin : Plugin<Project> {
schemaMappings.set(generate.schemaMappings)
inlineSchemaNameMappings.set(generate.inlineSchemaNameMappings)
inlineSchemaNameDefaults.set(generate.inlineSchemaNameDefaults)
openapiNormalizer.set(generate.openapiNormalizer)
invokerPackage.set(generate.invokerPackage)
groupId.set(generate.groupId)
id.set(generate.id)

View File

@ -162,6 +162,11 @@ open class OpenApiGeneratorGenerateExtension(project: Project) {
*/
val inlineSchemaNameDefaults = project.objects.mapProperty<String, String>()
/**
* Specifies mappings (rules) in OpenAPI normalizer
*/
val openapiNormalizer = project.objects.mapProperty<String, String>()
/**
* Root package for generated code.
*/

View File

@ -250,6 +250,13 @@ open class GenerateTask : DefaultTask() {
@Input
val inlineSchemaNameDefaults = project.objects.mapProperty<String, String>()
/**
* Specifies mappings (rules) in OpenAPI normalizer
*/
@Optional
@Input
val openapiNormalizer = project.objects.mapProperty<String, String>()
/**
* Root package for generated code.
*/
@ -758,6 +765,12 @@ open class GenerateTask : DefaultTask() {
}
}
if (openapiNormalizer.isPresent) {
openapiNormalizer.get().forEach { entry ->
configurator.addOpenAPINormalizer(entry.key, entry.value)
}
}
if (typeMappings.isPresent) {
typeMappings.get().forEach { entry ->
configurator.addTypeMapping(entry.key, entry.value)

View File

@ -315,6 +315,12 @@ public class CodeGenMojo extends AbstractMojo {
@Parameter(name = "inlineSchemaNameDefaults", property = "openapi.generator.maven.plugin.inlineSchemaNameDefaults")
private List<String> inlineSchemaNameDefaults;
/**
* A set of rules for OpenAPI normalizer
*/
@Parameter(name = "openapiNormalizer", property = "openapi.generator.maven.plugin.openapiNormalizer")
private List<String> openapiNormalizer;
/**
* A map of swagger spec types and the generated code types to use for them
*/
@ -700,6 +706,12 @@ public class CodeGenMojo extends AbstractMojo {
configurator);
}
// Retained for backwards-compatibility with configOptions -> openapi-normalizer
if (openapiNormalizer == null && configOptions.containsKey("openapi-normalizer")) {
applyOpenAPINormalizerKvp(configOptions.get("openapi-normalizer").toString(),
configurator);
}
// Retained for backwards-compatibility with configOptions -> type-mappings
if (typeMappings == null && configOptions.containsKey("type-mappings")) {
applyTypeMappingsKvp(configOptions.get("type-mappings").toString(), configurator);
@ -753,6 +765,11 @@ public class CodeGenMojo extends AbstractMojo {
applyInlineSchemaNameDefaultsKvpList(inlineSchemaNameDefaults, configurator);
}
// Apply OpenAPI normalizer rules
if (openapiNormalizer != null && (configOptions == null || !configOptions.containsKey("openapi-normalizer"))) {
applyOpenAPINormalizerKvpList(openapiNormalizer, configurator);
}
// Apply Type Mappings
if (typeMappings != null && (configOptions == null || !configOptions.containsKey("type-mappings"))) {
applyTypeMappingsKvpList(typeMappings, configurator);

View File

@ -147,6 +147,8 @@ public interface CodegenConfig {
Map<String, String> inlineSchemaNameDefault();
Map<String, String> openapiNormalizer();
Map<String, String> apiTemplateFiles();
Map<String, String> modelTemplateFiles();
@ -330,4 +332,7 @@ public interface CodegenConfig {
boolean getUseInlineModelResolver();
boolean getAddSuffixToDuplicateOperationNicknames();
boolean getUseOpenAPINormalizer();
}

View File

@ -167,6 +167,8 @@ public class DefaultCodegen implements CodegenConfig {
protected Map<String, String> inlineSchemaNameMapping = new HashMap<>();
// a map to store the inline schema naming conventions
protected Map<String, String> inlineSchemaNameDefault = new HashMap<>();
// a map to store the rules in OpenAPI Normalizer
protected Map<String, String> openapiNormalizer = new HashMap<>();
protected String modelPackage = "", apiPackage = "", fileSuffix;
protected String modelNamePrefix = "", modelNameSuffix = "";
protected String apiNamePrefix = "", apiNameSuffix = "Api";
@ -1137,6 +1139,11 @@ public class DefaultCodegen implements CodegenConfig {
return inlineSchemaNameDefault;
}
@Override
public Map<String, String> openapiNormalizer() {
return openapiNormalizer;
}
@Override
public String testPackage() {
return testPackage;
@ -7939,6 +7946,9 @@ public class DefaultCodegen implements CodegenConfig {
@Override
public boolean getUseInlineModelResolver() { return true; }
@Override
public boolean getUseOpenAPINormalizer() { return true; }
/*
A function to convert yaml or json ingested strings like property names
And convert special characters like newline, tab, carriage return

View File

@ -255,6 +255,12 @@ public class DefaultGenerator implements Generator {
config.processOpts();
// normalize the spec
if (config.getUseOpenAPINormalizer()) {
OpenAPINormalizer openapiNormalizer = new OpenAPINormalizer(openAPI, config.openapiNormalizer());
openapiNormalizer.normalize();
}
// resolve inline models
if (config.getUseInlineModelResolver()) {
InlineModelResolver inlineModelResolver = new InlineModelResolver();

View File

@ -0,0 +1,384 @@
/*
* 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 org.openapitools.codegen;
import io.swagger.v3.oas.models.*;
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.openapitools.codegen.utils.ModelUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.stream.Collectors;
public class OpenAPINormalizer {
private OpenAPI openAPI;
private Map<String, String> rules = new HashMap<>();
final Logger LOGGER = LoggerFactory.getLogger(OpenAPINormalizer.class);
// ============= a list of rules =============
// when set to true, all rules are enabled
final String ALL = "ALL";
boolean enableAll;
// when set to true, $ref in allOf is treated as parent so that x-parent: true will be added
// to the schema in $ref (if x-parent is not present)
final String REF_AS_PARENT_IN_ALLOF = "REF_AS_PARENT_IN_ALLOF";
boolean enableRefAsParentInAllOf;
// ============= end of rules =============
/**
* Initializes OpenAPI Normalizer with a set of rules
*
* @param openAPI OpenAPI
* @param rules a map of rules
*/
public OpenAPINormalizer(OpenAPI openAPI, Map<String, String> rules) {
this.openAPI = openAPI;
this.rules = rules;
parseRules(rules);
}
/**
* Parses the rules.
*
* @param rules a map of rules
*/
public void parseRules(Map<String, String> rules) {
if (rules == null) {
return;
}
if ("true".equalsIgnoreCase(rules.get(ALL))) {
enableAll = true;
}
if (enableAll || "true".equalsIgnoreCase(rules.get(REF_AS_PARENT_IN_ALLOF))) {
enableRefAsParentInAllOf = true;
}
}
/**
* Normalizes the OpenAPI input, which may not perfectly conform to
* the specification.
*/
void normalize() {
if (rules == null || rules.isEmpty()) {
return;
}
if (this.openAPI.getComponents() == null) {
this.openAPI.setComponents(new Components());
}
if (this.openAPI.getComponents().getSchemas() == null) {
this.openAPI.getComponents().setSchemas(new HashMap<String, Schema>());
}
normalizePaths();
normalizeComponents();
}
/**
* Normalizes inline models in Paths
*/
private void normalizePaths() {
Paths paths = openAPI.getPaths();
if (paths == null) {
return;
}
for (Map.Entry<String, PathItem> pathsEntry : paths.entrySet()) {
PathItem path = pathsEntry.getValue();
List<Operation> operations = new ArrayList<>(path.readOperations());
// Include callback operation as well
for (Operation operation : path.readOperations()) {
Map<String, Callback> callbacks = operation.getCallbacks();
if (callbacks != null) {
operations.addAll(callbacks.values().stream()
.flatMap(callback -> callback.values().stream())
.flatMap(pathItem -> pathItem.readOperations().stream())
.collect(Collectors.toList()));
}
}
for (Operation operation : operations) {
normalizeRequestBody(operation);
normalizeParameters(operation);
normalizeResponses(operation);
}
}
}
/**
* Normalizes schemas in content
*
* @param content target content
*/
private void normalizeContent(Content content) {
if (content == null || content.isEmpty()) {
return;
}
for (String contentType : content.keySet()) {
MediaType mediaType = content.get(contentType);
if (mediaType == null) {
continue;
} else if (mediaType.getSchema() == null) {
continue;
} else {
normalizeSchema(mediaType.getSchema(), new HashSet<>());
}
}
}
/**
* Normalizes schemas in RequestBody
*
* @param operation target operation
*/
private void normalizeRequestBody(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;
}
}
normalizeContent(requestBody.getContent());
}
/**
* Normalizes schemas in parameters
*
* @param operation target operation
*/
private void normalizeParameters(Operation operation) {
List<Parameter> parameters = operation.getParameters();
if (parameters == null) {
return;
}
for (Parameter parameter : parameters) {
if (parameter.getSchema() == null) {
continue;
} else {
normalizeSchema(parameter.getSchema(), new HashSet<>());
}
}
}
/**
* Normalizes schemas in ApiResponses
*
* @param operation target operation
*/
private void normalizeResponses(Operation operation) {
ApiResponses responses = operation.getResponses();
if (responses == null) {
return;
}
for (Map.Entry<String, ApiResponse> responsesEntry : responses.entrySet()) {
if (responsesEntry.getValue() == null) {
continue;
} else {
normalizeContent(responsesEntry.getValue().getContent());
}
}
}
/**
* Normalizes schemas in components
*/
private void normalizeComponents() {
Map<String, Schema> schemas = openAPI.getComponents().getSchemas();
if (schemas == null) {
return;
}
List<String> schemaNames = new ArrayList<String>(schemas.keySet());
for (String schemaName : schemaNames) {
Schema schema = schemas.get(schemaName);
if (schema == null) {
LOGGER.warn("{} not fount found in openapi/components/schemas.", schemaName);
} else {
normalizeSchema(schema, new HashSet<>());
}
}
}
/**
* Normalizes a schema
*
* @param schema Schema
* @param visitedSchemas a set of visited schemas
*/
public void normalizeSchema(Schema schema, Set<Schema> visitedSchemas) {
if (schema == null) {
return;
}
if (StringUtils.isNotEmpty(schema.get$ref())) {
// not need to process $ref
return;
}
if ((visitedSchemas.contains(schema))) {
return; // skip due to circular reference
} else {
visitedSchemas.add(schema);
}
if (schema instanceof ArraySchema) {
normalizeSchema(schema.getItems(), visitedSchemas);
} else if (schema.getAdditionalProperties() instanceof Schema) { // map
normalizeSchema((Schema) schema.getAdditionalProperties(), visitedSchemas);
} else if (ModelUtils.isComposedSchema(schema)) {
ComposedSchema m = (ComposedSchema) schema;
if (m.getAllOf() != null && !m.getAllOf().isEmpty()) {
normalizeAllOf(m, visitedSchemas);
}
if (m.getOneOf() != null && !m.getOneOf().isEmpty()) {
normalizeOneOf(m, visitedSchemas);
}
if (m.getAnyOf() != null && !m.getAnyOf().isEmpty()) {
normalizeAnyOf(m, visitedSchemas);
}
if (m.getProperties() != null && !m.getProperties().isEmpty()) {
normalizeProperties(m.getProperties(), visitedSchemas);
}
if (m.getAdditionalProperties() != null) {
// normalizeAdditionalProperties(m);
}
} else if (schema.getNot() != null) {// not schema
normalizeSchema(schema.getNot(), visitedSchemas);
} else if (schema.getProperties() != null && !schema.getProperties().isEmpty()) {
normalizeProperties(schema.getProperties(), visitedSchemas);
} else if (schema instanceof Schema) {
normalizeNonComposedSchema(schema, visitedSchemas);
} else {
throw new RuntimeException("Unknown schema type found in normalizer: " + schema);
}
}
private void normalizeNonComposedSchema(Schema schema, Set<Schema> visitedSchemas) {
// normalize non-composed schema (e.g. schema with only properties)
}
private void normalizeProperties(Map<String, Schema> properties, Set<Schema> visitedSchemas) {
if (properties == null) {
return;
}
for (Map.Entry<String, Schema> propertiesEntry : properties.entrySet()) {
Schema property = propertiesEntry.getValue();
normalizeSchema(property, visitedSchemas);
}
}
private void normalizeAllOf(Schema schema, Set<Schema> visitedSchemas) {
for (Object item : schema.getAllOf()) {
if (!(item instanceof Schema)) {
throw new RuntimeException("Error! allOf schema is not of the type Schema: " + item);
}
// normalize allOf sub schemas one by one
normalizeSchema((Schema) item, visitedSchemas);
}
// process rules here
processUseAllOfRefAsParent(schema);
}
private void normalizeOneOf(Schema schema, Set<Schema> visitedSchemas) {
for (Object item : schema.getAllOf()) {
if (!(item instanceof Schema)) {
throw new RuntimeException("Error! allOf schema is not of the type Schema: " + item);
}
// normalize oenOf sub schemas one by one
normalizeSchema((Schema) item, visitedSchemas);
}
// process rules here
}
private void normalizeAnyOf(Schema schema, Set<Schema> visitedSchemas) {
for (Object item : schema.getAllOf()) {
if (!(item instanceof Schema)) {
throw new RuntimeException("Error! allOf schema is not of the type Schema: " + item);
}
// normalize anyOf sub schemas one by one
normalizeSchema((Schema) item, visitedSchemas);
}
// process rules here
}
// ===================== a list of rules =====================
// all rules (fuctions) start with the word "process"
private void processUseAllOfRefAsParent(Schema schema) {
if (!enableRefAsParentInAllOf) {
return;
}
for (Object item : schema.getAllOf()) {
if (!(item instanceof Schema)) {
throw new RuntimeException("Error! allOf schema is not of the type Schema: " + item);
}
Schema s = (Schema) item;
if (StringUtils.isNotEmpty(s.get$ref())) {
String ref = ModelUtils.getSimpleRef(s.get$ref());
// TODO need to check for requestBodies?
Schema refSchema = openAPI.getComponents().getSchemas().get(ref);
if (refSchema == null) {
throw new RuntimeException("schema cannot be null with ref " + ref);
}
if (refSchema.getExtensions() == null) {
refSchema.setExtensions(new HashMap<>());
}
if (refSchema.getExtensions().containsKey("x-parent")) {
// doing nothing as x-parent already exists
} else {
refSchema.getExtensions().put("x-parent", true);
}
LOGGER.debug("processUseAllOfRefAsParent added `x-parent: true` to {}", refSchema);
}
}
}
// ===================== end of rules =====================
}

View File

@ -71,6 +71,7 @@ public class CodegenConfigurator {
private Map<String, String> schemaMappings = new HashMap<>();
private Map<String, String> inlineSchemaNameMappings = new HashMap<>();
private Map<String, String> inlineSchemaNameDefaults = new HashMap<>();
private Map<String, String> openapiNormalizer = new HashMap<>();
private Set<String> languageSpecificPrimitives = new HashSet<>();
private Map<String, String> reservedWordsMappings = new HashMap<>();
private Map<String, String> serverVariables = new HashMap<>();
@ -123,6 +124,9 @@ public class CodegenConfigurator {
if(generatorSettings.getInlineSchemaNameDefaults() != null) {
configurator.inlineSchemaNameDefaults.putAll(generatorSettings.getInlineSchemaNameDefaults());
}
if(generatorSettings.getOpenAPINormalizer() != null) {
configurator.openapiNormalizer.putAll(generatorSettings.getOpenAPINormalizer());
}
if(generatorSettings.getLanguageSpecificPrimitives() != null) {
configurator.languageSpecificPrimitives.addAll(generatorSettings.getLanguageSpecificPrimitives());
}
@ -210,6 +214,12 @@ public class CodegenConfigurator {
return this;
}
public CodegenConfigurator addOpenAPINormalizer(String key, String value) {
this.openapiNormalizer.put(key, value);
generatorSettingsBuilder.withOpenAPINormalizer(key, value);
return this;
}
public CodegenConfigurator addInstantiationType(String key, String value) {
this.instantiationTypes.put(key, value);
generatorSettingsBuilder.withInstantiationType(key, value);
@ -382,6 +392,12 @@ public class CodegenConfigurator {
return this;
}
public CodegenConfigurator setOpenAPINormalizer(Map<String, String> openapiNormalizer) {
this.openapiNormalizer = openapiNormalizer;
generatorSettingsBuilder.withOpenAPINormalizer(openapiNormalizer);
return this;
}
public CodegenConfigurator setInputSpec(String inputSpec) {
this.inputSpec = inputSpec;
workflowSettingsBuilder.withInputSpec(inputSpec);
@ -661,6 +677,7 @@ public class CodegenConfigurator {
config.schemaMapping().putAll(generatorSettings.getSchemaMappings());
config.inlineSchemaNameMapping().putAll(generatorSettings.getInlineSchemaNameMappings());
config.inlineSchemaNameDefault().putAll(generatorSettings.getInlineSchemaNameDefaults());
config.openapiNormalizer().putAll(generatorSettings.getOpenAPINormalizer());
config.languageSpecificPrimitives().addAll(generatorSettings.getLanguageSpecificPrimitives());
config.reservedWordsMappings().putAll(generatorSettings.getReservedWordsMappings());
config.additionalProperties().putAll(generatorSettings.getAdditionalProperties());

View File

@ -120,6 +120,19 @@ public final class CodegenConfiguratorUtils {
}
}
public static void applyOpenAPINormalizerKvpList(List<String> openapiNormalizer, CodegenConfigurator configurator) {
for (String propString : openapiNormalizer) {
applyOpenAPINormalizerKvp(propString, configurator);
}
}
public static void applyOpenAPINormalizerKvp(String openapiNormalizer, CodegenConfigurator configurator) {
final Map<String, String> map = createMapFromKeyValuePairs(openapiNormalizer);
for (Map.Entry<String, String> entry : map.entrySet()) {
configurator.addOpenAPINormalizer(entry.getKey().trim(), entry.getValue().trim());
}
}
public static void applyTypeMappingsKvpList(List<String> typeMappings, CodegenConfigurator configurator) {
for (String propString : typeMappings) {
applyTypeMappingsKvp(propString, configurator);

View File

@ -308,15 +308,15 @@ public class ModelUtils {
/**
* Invoke the specified visitor function for every schema that matches mimeType in the OpenAPI document.
*
* <p>
* 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 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.
* @param visitor the visitor function which is invoked for every visited schema.
*/
private static void visitSchema(OpenAPI openAPI, Schema schema, String mimeType, List<String> visitedSchemas, OpenAPISchemaVisitor visitor) {
visitor.visit(schema, mimeType);
@ -425,13 +425,14 @@ public class ModelUtils {
/**
* Return true if the specified schema is an object with a fixed number of properties.
*
* <p>
* 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.
*
* <p>
* For example, an OpenAPI schema is considered an ObjectSchema in the following scenarios:
* <p>
*
* type: object
* additionalProperties: false
@ -479,16 +480,17 @@ public class ModelUtils {
* 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.
*
* <p>
* 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.
*
* The payload may include dynamic properties.
* <p>
* Note that isMapSchema returns true for a composed schema (allOf, anyOf, oneOf) that also defines
* additionalproperties.
*
* <p>
* For example, an OpenAPI schema is considered a MapSchema in the following scenarios:
* <p>
*
* type: object
* additionalProperties: true
@ -772,14 +774,14 @@ public class ModelUtils {
/**
* Check to see if the schema is a free form object.
*
* <p>
* 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: {}.
*
* <p>
* Examples:
*
* <p>
* components:
* schemas:
* arbitraryObject:
@ -798,7 +800,7 @@ public class ModelUtils {
* The value can be any type except the 'null' value.
*
* @param openAPI the object that encapsulates the OAS document.
* @param schema potentially containing a '$ref'
* @param schema potentially containing a '$ref'
* @return true if it's a free-form object
*/
public static boolean isFreeFormObject(OpenAPI openAPI, Schema schema) {
@ -1054,10 +1056,10 @@ public class ModelUtils {
/**
* Return the first Schema from a specified OAS 'content' section.
*
* <p>
* For example, given the following OAS, this method returns the schema
* for the 'application/json' content type because it is listed first in the OAS.
*
* <p>
* responses:
* '200':
* content:
@ -1099,8 +1101,8 @@ public class ModelUtils {
/**
* Has self reference?
*
* @param openAPI OpenAPI spec.
* @param schema Schema
* @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
*/
@ -1257,7 +1259,7 @@ public class ModelUtils {
/**
* Returns the additionalProperties Schema for the specified input schema.
*
* <p>
* 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.
@ -1267,9 +1269,9 @@ public class ModelUtils {
* to the boolean value True or setting additionalProperties: {}
*
* @param openAPI the object that encapsulates the OAS document.
* @param schema the input schema that may or may not have the additionalProperties keyword.
* @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.
* properties are allowed.
*/
public static Schema getAdditionalProperties(OpenAPI openAPI, Schema schema) {
Object addProps = schema.getAdditionalProperties();
@ -1380,10 +1382,10 @@ public class ModelUtils {
* 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.
*
* <p>
* For example, given the following OAS spec, the parent of 'Dog' is Animal
* because 'Animal' specifies a discriminator.
*
* <p>
* animal:
* type: object
* discriminator:
@ -1391,6 +1393,7 @@ public class ModelUtils {
* properties:
* type: string
*
* <p>
* dog:
* allOf:
* - $ref: '#/components/schemas/animal'
@ -1418,10 +1421,10 @@ public class ModelUtils {
LOGGER.error("Failed to obtain schema from {}", parentName);
return "UNKNOWN_PARENT_NAME";
} else if (hasOrInheritsDiscriminator(s, allSchemas)) {
// discriminator.propertyName is used
// discriminator.propertyName is used or x-parent is used
return parentName;
} else {
// not a parent since discriminator.propertyName is not set
// not a parent since discriminator.propertyName or x-parent is not set
hasAmbiguousParents = true;
refedWithoutDiscriminator.add(parentName);
}
@ -1476,7 +1479,7 @@ public class ModelUtils {
LOGGER.error("Failed to obtain schema from {}", parentName);
names.add("UNKNOWN_PARENT_NAME");
} else if (hasOrInheritsDiscriminator(s, allSchemas)) {
// discriminator.propertyName is used
// discriminator.propertyName is used or x-parent is used
names.add(parentName);
if (includeAncestors && s instanceof ComposedSchema) {
names.addAll(getAllParentsName((ComposedSchema) s, allSchemas, true));
@ -1501,7 +1504,8 @@ public class ModelUtils {
}
private static boolean hasOrInheritsDiscriminator(Schema schema, Map<String, Schema> allSchemas) {
if (schema.getDiscriminator() != null && StringUtils.isNotEmpty(schema.getDiscriminator().getPropertyName())) {
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());
@ -1523,18 +1527,43 @@ public class ModelUtils {
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.getExtensions() == null) {
return false;
} else {
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;
}
}
}
/**
* Return true if the 'nullable' attribute is set to true in the schema, i.e. if the value
* of the property can be the null value.
*
* <p>
* In addition, if the OAS document is 3.1 or above, isNullable returns true if the input
* schema is a 'oneOf' composed document with at most two children, and one of the children
* is the 'null' type.
*
* <p>
* The caller is responsible for resolving schema references before invoking isNullable.
* If the input schema is a $ref and the referenced schema has 'nullable: true', this method
* returns false (because the nullable attribute is defined in the referenced schema).
*
* <p>
* The 'nullable' attribute was introduced in OAS 3.0.
* The 'nullable' attribute is deprecated in OAS 3.1. In a OAS 3.1 document, the preferred way
* to specify nullable properties is to use the 'null' type.
@ -1564,11 +1593,11 @@ public class ModelUtils {
/**
* Return true if the specified composed schema is 'oneOf', contains one or two elements,
* and at least one of the elements is the 'null' type.
*
* <p>
* The 'null' type is supported in OAS 3.1 and above.
* In the example below, the 'OptionalOrder' can have the null value because the 'null'
* type is one of the elements under 'oneOf'.
*
* <p>
* OptionalOrder:
* oneOf:
* - type: 'null'
@ -1591,13 +1620,13 @@ public class ModelUtils {
/**
* isNullType returns true if the input schema is the 'null' type.
*
* <p>
* The 'null' type is supported in OAS 3.1 and above. It is not supported
* in OAS 2.0 and OAS 3.0.x.
*
* <p>
* For example, the "null" type could be used to specify that a value must
* either be null or a specified type:
*
* <p>
* OptionalOrder:
* oneOf:
* - type: 'null'
@ -1617,6 +1646,7 @@ public class ModelUtils {
* 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
*/
@ -1713,7 +1743,7 @@ public class ModelUtils {
private static ObjectMapper getRightMapper(String data) {
ObjectMapper mapper;
if (data.trim().startsWith("{")) {
if (data.trim().startsWith("{")) {
mapper = JSON_MAPPER;
} else {
mapper = YAML_MAPPER;
@ -1725,11 +1755,9 @@ public class ModelUtils {
* Parse and return a JsonNode representation of the input OAS document.
*
* @param location the URL of the OAS document.
* @param auths the list of authorization values to access the remote URL.
*
* @throws java.lang.Exception if an error occurs while retrieving the OpenAPI document.
*
* @param auths the list of authorization values to access the remote URL.
* @return A JsonNode representation of the input OAS document.
* @throws java.lang.Exception if an error occurs while retrieving the OpenAPI document.
*/
public static JsonNode readWithInfo(String location, List<AuthorizationValue> auths) throws Exception {
String data;
@ -1756,14 +1784,13 @@ public class ModelUtils {
/**
* Parse the OAS document at the specified location, get the swagger or openapi version
* as specified in the source document, and return the version.
*
* <p>
* For OAS 2.0 documents, return the value of the 'swagger' attribute.
* For OAS 3.x documents, return the value of the 'openapi' attribute.
*
* @param openAPI the object that encapsulates the OAS document.
* @param openAPI the object that encapsulates the OAS document.
* @param location the URL of the OAS document.
* @param auths the list of authorization values to access the remote URL.
*
* @param auths the list of authorization values to access the remote URL.
* @return the version of the OpenAPI document.
*/
public static SemVer getOpenApiVersion(OpenAPI openAPI, String location, List<AuthorizationValue> auths) {

View File

@ -56,7 +56,6 @@ import java.util.stream.Collectors;
import static org.testng.Assert.*;
public class DefaultCodegenTest {
@Test
@ -4300,4 +4299,29 @@ public class DefaultCodegenTest {
Assert.assertFalse(inlineEnumSchemaProperty.isContainer);
Assert.assertFalse(inlineEnumSchemaProperty.isPrimitiveType);
}
@Test
public void testOpenAPINormalizer() {
OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/allOf_extension_parent.yaml");
Schema schema = openAPI.getComponents().getSchemas().get("AnotherPerson");
assertNull(schema.getExtensions());
Schema schema2 = openAPI.getComponents().getSchemas().get("Person");
assertEquals(schema2.getExtensions().get("x-parent"), "abstract");
Map<String, String> options = new HashMap<>();
options.put("REF_AS_PARENT_IN_ALLOF", "true");
OpenAPINormalizer openAPINormalizer = new OpenAPINormalizer(openAPI, options);
openAPINormalizer.normalize();
Schema schema3 = openAPI.getComponents().getSchemas().get("AnotherPerson");
assertEquals(schema3.getExtensions().get("x-parent"), true);
Schema schema4 = openAPI.getComponents().getSchemas().get("AnotherParent");
assertEquals(schema4.getExtensions().get("x-parent"), true);
Schema schema5 = openAPI.getComponents().getSchemas().get("Person");
assertEquals(schema5.getExtensions().get("x-parent"), "abstract");
}
}

View File

@ -1717,4 +1717,39 @@ public class JavaClientCodegenTest {
"localVarQueryParams.addAll(ApiClient.parameterToPairs(\"multi\", \"values\", queryObject.getValues()));"
);
}
@Test
public void testJdkHttpClientWithAndWithoutParentExtension() throws Exception {
Map<String, Object> properties = new HashMap<>();
properties.put(CodegenConstants.API_PACKAGE, "xyz.abcdef.api");
properties.put(CodegenConstants.MODEL_PACKAGE, "xyz.abcdef.model");
properties.put(CodegenConstants.INVOKER_PACKAGE, "xyz.abcdef.invoker");
File output = Files.createTempDirectory("test").toFile();
output.deleteOnExit();
final CodegenConfigurator configurator = new CodegenConfigurator()
.setGeneratorName("java")
// use default `okhttp-gson`
//.setLibrary(JavaClientCodegen.NATIVE)
.setAdditionalProperties(properties)
.setInputSpec("src/test/resources/3_0/allOf_extension_parent.yaml")
.setOutputDir(output.getAbsolutePath().replace("\\", "/"));
final ClientOptInput clientOptInput = configurator.toClientOptInput();
DefaultGenerator generator = new DefaultGenerator();
generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true");
generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "true");
List<File> files = generator.opts(clientOptInput).generate();
Assert.assertEquals(files.size(), 30);
validateJavaSourceFiles(files);
TestUtils.assertFileContains(Paths.get(output + "/src/main/java/xyz/abcdef/model/Child.java"),
"public class Child extends Person {");
TestUtils.assertFileContains(Paths.get(output + "/src/main/java/xyz/abcdef/model/Adult.java"),
"public class Adult extends Person {");
TestUtils.assertFileContains(Paths.get(output + "/src/main/java/xyz/abcdef/model/AnotherChild.java"),
"public class AnotherChild {");
}
}

View File

@ -0,0 +1,87 @@
openapi: 3.0.1
info:
version: 1.0.0
title: Example
license:
name: MIT
servers:
- url: http://api.example.xyz/v1
paths:
/person/display/{personId}:
get:
parameters:
- name: personId
in: path
required: true
description: The id of the person to retrieve
schema:
type: string
operationId: list
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/Person"
components:
schemas:
Person:
description: person using x-parent (abstract) to indicate it's a parent class
type: object
x-parent: "abstract"
properties:
$_type:
type: string
lastName:
type: string
firstName:
type: string
Adult:
description: A representation of an adult
allOf:
- $ref: '#/components/schemas/Person'
- type: object
properties:
children:
type: array
items:
$ref: "#/components/schemas/Child"
Child:
description: A representation of a child
allOf:
- type: object
properties:
age:
type: integer
format: int32
- $ref: '#/components/schemas/Person'
AnotherChild:
description: another child class that does NOT extend/inherit AnotherPerson
allOf:
- type: object
properties:
age:
type: integer
format: int32
- $ref: '#/components/schemas/AnotherPerson'
AnotherPerson:
description: person object without x-parent extension
type: object
allOf:
- properties:
$_type:
type: string
lastName:
type: string
firstName:
type: string
- $ref: '#/components/schemas/AnotherParent'
AnotherParent:
description: parent object without x-parent extension
type: object
properties:
isParent:
type: boolean
mum_or_dad:
type: string