diff --git a/docs/customization.md b/docs/customization.md index c529eb4bc5f9..cc731bacfb65 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -681,6 +681,35 @@ index 6f27abd..146c61c 100644 type: string ``` +- `USE_UNWRAPPED_FOR_COMPOSITE_ONEOF` set to true to unwrap oneOf combined with allOf/properties and without discriminator (aka "composite" oneOf). Set the vendorExtension X_ONE_OF_UNWRAPPED to be used by generators. For example the java generator annotates the new oneOf property with the jackson `@JsonUnwrapped` annotation + +Example: +``` +java -jar modules/openapi-generator-cli/target/openapi-generator-cli.jar generate -g java -i modules/openapi-generator/src/test/resources/3_0/oneOf_unwrap_mixed.yaml -o /tmp/java/ --openapi-normalizer USE_UNWRAPPED_FOR_COMPOSITE_ONEOF=true +``` + +Here is what the change in the spec looks like: +```diff +diff --git a/api/openapi.yaml b/api/openapi.yaml +index 6f27abd..146c61c 100644 +--- a/api/openapi.yaml ++++ b/api/openapi.yaml +@@ -9,10 +9,10 @@ components: + schemas: + Account: +- oneOf: +- - $ref: '#/components/schemas/LegacyBankNumber' +- - ref: '#/components/schemas/WireTransferInfo' + properties: + bank: + $ref: '#/components/schemas/Bank' ++ oneOf: ++ X_ONE_OF_UNWRAPPED: true ++ oneOf: ++ - ref: '#/components/schemas/LegacyBankNumber' ++ - ref: '#/components/schemas/WireTransferInfo' +``` + - `FILTER` The `FILTER` parameter allows selective inclusion of API operations based on specific criteria. It applies the `x-internal: true` property to operations that do **not** match the specified values, preventing them from being generated. Multiple filters can be separated by a semicolon. diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java index b2a4de1201e9..7dca870ef15d 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java @@ -501,4 +501,11 @@ public static enum ENUM_PROPERTY_NAMING_TYPE {camelCase, PascalCase, snake_case, public static final String X_NULLABLE = "x-nullable"; public static final String X_ENUM_VARNAMES = "x-enum-varnames"; public static final String X_ENUM_DESCRIPTIONS = "x-enum-descriptions"; + + public static final String X_ONE_OF_JSON_CREATOR = "x-oneof-jsonCreator"; + public static final String X_ONE_OF_UNWRAPPED = "x-oneOfunwrapped"; + public static final String JACKSON2_PACKAGE = "com.fasterxml.jackson"; + public static final String JACKSON3_PACKAGE = "tools.jackson"; + public static final String JACKSON_PACKAGE = "jacksonPackage"; + } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java index 8c4202579e7e..aa12f59aae09 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java @@ -159,6 +159,7 @@ public class OpenAPINormalizer { // when set to true, sort model properties by name to ensure deterministic output final String SORT_MODEL_PROPERTIES = "SORT_MODEL_PROPERTIES"; + final String USE_UNWRAPPED_FOR_COMPOSITE_ONEOF = "USE_UNWRAPPED_FOR_COMPOSITE_ONEOF"; // ============= end of rules ============= /** @@ -219,6 +220,7 @@ public OpenAPINormalizer(OpenAPI openAPI, Map inputRules) { ruleNames.add(REMOVE_PROPERTIES_FROM_TYPE_OTHER_THAN_OBJECT); ruleNames.add(SORT_MODEL_PROPERTIES); ruleNames.add(REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING); + ruleNames.add(USE_UNWRAPPED_FOR_COMPOSITE_ONEOF); // rules that are default to true rules.put(SIMPLIFY_ONEOF_ANYOF, true); @@ -770,15 +772,15 @@ public Schema normalizeSchema(Schema schema, Set visitedSchemas) { } if (ModelUtils.hasAllOf(schema)) { - return normalizeAllOf(schema, visitedSchemas); + schema = normalizeAllOf(schema, visitedSchemas); } if (ModelUtils.hasOneOf(schema)) { - return normalizeOneOf(schema, visitedSchemas); + schema = normalizeOneOf(schema, visitedSchemas); } if (ModelUtils.hasAnyOf(schema)) { - return normalizeAnyOf(schema, visitedSchemas); + schema = normalizeAnyOf(schema, visitedSchemas); } if (ModelUtils.hasProperties(schema)) { @@ -1079,6 +1081,8 @@ protected Schema normalizeOneOf(Schema schema, Set visitedSchemas) { schema.getOneOf().set(i, normalizeSchema((Schema) item, visitedSchemas)); } schema = processReplaceOneOfByMapping(schema); + schema = processUnwrapCompositeOneOf(schema); + } else { // normalize it as it's no longer an oneOf schema = normalizeSchema(schema, visitedSchemas); @@ -1087,6 +1091,64 @@ protected Schema normalizeOneOf(Schema schema, Set visitedSchemas) { return schema; } + /* + * handling of USE_UNWRAPPED_FOR_COMPOSITE_ONEOF rule. + */ + protected Schema processUnwrapCompositeOneOf(Schema schema) { + if (!getRule(USE_UNWRAPPED_FOR_COMPOSITE_ONEOF)) { + return schema; + } + if (skipUnwrapOneOf(schema)) { + return schema; + } + + Schema newOneOfSchema = new ComposedSchema(); + newOneOfSchema.oneOf(new ArrayList<>(schema.getOneOf())); + newOneOfSchema.addExtension(X_ONE_OF_UNWRAPPED, true); + schema.oneOf(null); + // TODO: configuration of the property name + String propertyName = "oneOf"; + if (ModelUtils.hasProperties(schema)) { + schema.getProperties().put(propertyName, newOneOfSchema); + } else if (ModelUtils.hasAllOf(schema)) { + Schema allOfSchema = new Schema(); + allOfSchema.addProperty(propertyName, newOneOfSchema); + schema.getAllOf().add(allOfSchema); + } + + return schema; + } + + /** + * Check if unwrapping oneOf is not relevant. + * + * @return true if unwrapping of OneOf must be skipped + */ + protected boolean skipUnwrapOneOf(Schema schema) { + if (!(ModelUtils.hasOneOf(schema) && (ModelUtils.hasProperties(schema) || ModelUtils.hasAllOf(schema)))) { + return true; + } + + // skip handling of oneOf + properties + discriminator + // accept discriminator mappings NOT matching the oneOf elements. + Discriminator discriminator = schema.getDiscriminator(); + boolean hasDiscriminator = schema.getDiscriminator() != null; + if (hasDiscriminator) { + List oneOfs = schema.getOneOf(); + if (oneOfs.stream().allMatch(oneOf -> oneOf != null && oneOf.get$ref() != null)) { + // skip normalization if discriminator but not maping + if (discriminator.getMapping() == null && discriminator.getPropertyName() != null) { + return true; + } + // if same size for mapping and oneOf, assume that we skip this normalization + if (discriminator.getMapping() != null && discriminator.getMapping().size() == schema.getOneOf().size()) { + return true; + } + } + } + return false; + } + protected Schema normalizeAnyOf(Schema schema, Set visitedSchemas) { //transform anyOf into enums if needed schema = processSimplifyAnyOfEnum(schema); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java index 6cf3ca55fee2..65c7eae86d66 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java @@ -30,10 +30,7 @@ import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.PathItem; import io.swagger.v3.oas.models.examples.Example; -import io.swagger.v3.oas.models.media.Content; -import io.swagger.v3.oas.models.media.MediaType; -import io.swagger.v3.oas.models.media.Schema; -import io.swagger.v3.oas.models.media.StringSchema; +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.servers.Server; @@ -75,8 +72,8 @@ import java.util.stream.Stream; import java.util.stream.StreamSupport; -import static org.openapitools.codegen.CodegenConstants.USE_DEDUCTION_FOR_ONE_OF_INTERFACES; -import static org.openapitools.codegen.CodegenConstants.X_IMPLEMENTS; +import static org.openapitools.codegen.CodegenConstants.*; +import static org.openapitools.codegen.VendorExtension.X_FIELD_EXTRA_ANNOTATION; import static org.openapitools.codegen.utils.CamelizeOption.*; import static org.openapitools.codegen.utils.ModelUtils.getSchemaItems; import static org.openapitools.codegen.utils.OnceLogger.once; @@ -228,6 +225,8 @@ protected enum ENUM_PROPERTY_NAMING_TYPE {MACRO_CASE, legacy, original} protected JSpecifyNullableLambda jSpecifyNullableLambda; @Getter @Setter protected boolean useDeductionForOneOfInterfaces = false; + @Getter @Setter + protected boolean useWrapperForMixedOneOf; private Map schemaKeyToModelNameCache = new HashMap<>(); @@ -646,6 +645,7 @@ public void processOpts() { importMapping.put("JsonIgnore", "com.fasterxml.jackson.annotation.JsonIgnore"); importMapping.put("JsonIgnoreProperties", "com.fasterxml.jackson.annotation.JsonIgnoreProperties"); importMapping.put("JsonInclude", "com.fasterxml.jackson.annotation.JsonInclude"); + importMapping.put("JsonUnwrapped", "com.fasterxml.jackson.annotation.JsonUnwrapped"); if (openApiNullable) { importMapping.put("JsonNullable", "org.openapitools.jackson.nullable.JsonNullable"); } @@ -2013,6 +2013,10 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert if (property.dataType != null && property.dataType.equals(property.name) && property.dataType.toUpperCase(Locale.ROOT).equals(property.name)) { property.name = property.name.toLowerCase(Locale.ROOT); } + if (property.getVendorExtensions().containsKey(X_ONE_OF_UNWRAPPED)) { + model.imports.add("JsonUnwrapped"); + property.getVendorExtensions().put(X_FIELD_EXTRA_ANNOTATION.getName(), "@JsonUnwrapped"); + } } @Override @@ -2668,7 +2672,7 @@ public List getSupportedVendorExtensions() { extensions.add(VendorExtension.X_ACCEPTS); extensions.add(VendorExtension.X_CONTENT_TYPE); extensions.add(VendorExtension.X_CLASS_EXTRA_ANNOTATION); - extensions.add(VendorExtension.X_FIELD_EXTRA_ANNOTATION); + extensions.add(X_FIELD_EXTRA_ANNOTATION); return extensions; } @@ -2813,4 +2817,70 @@ protected void addNullableImportForOperation(CodegenOperation codegenOperation) .findAny() .ifPresent(param -> codegenOperation.imports.add("Nullable")); } + + @Override + public Map updateAllModels(Map objs) { + objs = super.updateAllModels(objs); + if (jackson && useOneOfInterfaces) { + // handling of X_ONE_OF_UNWRAPPED with inheritance + for (ModelsMap obj : objs.values()) { + for (ModelMap mo : obj.getModels()) { + CodegenModel cm = mo.getModel(); + if (cm.getVendorExtensions().containsKey(X_ONE_OF_UNWRAPPED) && cm.getInterfaceModels() != null) { + addOneOfMixinSupport(obj, cm); + } + } + } + } + return objs; + } + + /** + * Add JsonCreator and mixin interface to the oneOf interface. + *

+ * Add the necessary imports. + *

+ * Construct a vendorExtension X_ONE_OF_JSON_CREATOR with a map containing: + *

    + *
  1. >mapper: JsonMapper or ObjectMapper + *
  2. mixins: a list with the class name of the oneOf classes + *
+ */ + protected void addOneOfMixinSupport(ModelsMap obj, CodegenModel cm) { + String configPackage = getConfigPackage(); + Map config; + if (supportingFiles.stream().noneMatch(sf -> "JacksonMixinConfig.java".equals(sf.getDestinationFilename()))) { + supportingFiles.add(new SupportingFile("jacksonMixinConfig.mustache", + (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), + "JacksonMixinConfig.java")); + config = Map.of( + "mapper", isUseJackson3() ? "JsonMapper" : "ObjectMapper", + "mixins", new ArrayList<>()); + vendorExtensions.put("x-jacksonMixinConfig", config); + } else { + config = ( Map)vendorExtensions.get("x-jacksonMixinConfig"); + } + cm.vendorExtensions.put(X_ONE_OF_JSON_CREATOR, config); + ((List)config.get("mixins")).add(cm.classname); + + if (!isUseJackson3()) { + obj.getImports().add(Map.of("import", "com.fasterxml.jackson.core.JsonProcessingException")); + } + obj.getImports().add(Map.of("import", (isUseJackson3()? JACKSON3_PACKAGE:JACKSON2_PACKAGE) + ".databind.JsonNode")); + obj.getImports().add(Map.of("import", configPackage + ".JacksonMixinConfig")); + + } + + /* + * return the config package. + * + * by default use invokerPackage + */ + protected String getConfigPackage() { + return invokerPackage; + } + + protected boolean isUseJackson3() { + return false; + } } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java index d3b8e0f16bbc..d50ee8fd587b 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java @@ -46,6 +46,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import static org.openapitools.codegen.CodegenConstants.*; import static org.openapitools.codegen.utils.CamelizeOption.LOWERCASE_FIRST_LETTER; import static org.openapitools.codegen.utils.StringUtils.*; @@ -58,9 +59,6 @@ public abstract class AbstractKotlinCodegen extends DefaultCodegen implements Co public static final String JAVAX_PACKAGE = "javaxPackage"; public static final String USE_JAKARTA_EE = "useJakartaEe"; public static final String USE_JACKSON_3 = "useJackson3"; - public static final String JACKSON2_PACKAGE = "com.fasterxml.jackson"; - public static final String JACKSON3_PACKAGE = "tools.jackson"; - public static final String JACKSON_PACKAGE = "jacksonPackage"; public static final String SCHEMA_IMPLEMENTS = "schemaImplements"; public static final String SCHEMA_IMPLEMENTS_FIELDS = "schemaImplementsFields"; public static final String X_KOTLIN_IMPLEMENTS_SKIP = "xKotlinImplementsSkip"; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaClientCodegen.java index 1cad2361a810..8b26a63ecb63 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/JavaClientCodegen.java @@ -127,10 +127,6 @@ public class JavaClientCodegen extends AbstractJavaCodegen public static final String SERIALIZATION_LIBRARY_JSONB = "jsonb"; public static final String USE_SPRING_BOOT4 = "useSpringBoot4"; - private static final String JACKSON2_PACKAGE = "com.fasterxml.jackson"; - private static final String JACKSON3_PACKAGE = "tools.jackson"; - private static final String JACKSON_PACKAGE = "jacksonPackage"; - public static final String GENERATE_CLIENT_AS_BEAN = "generateClientAsBean"; protected String gradleWrapperPackage = "gradle.wrapper"; @@ -1322,10 +1318,12 @@ public void setCaseInsensitiveResponseHeaders(final Boolean caseInsensitiveRespo protected void applyJackson2Package() { writePropertyBack(JACKSON_PACKAGE, JACKSON2_PACKAGE); + importMapping.put("JsonMapper", JACKSON2_PACKAGE + ".databind.json.JsonMapper"); } protected void applyJackson3Package() { writePropertyBack(JACKSON_PACKAGE, JACKSON3_PACKAGE); + importMapping.put("JsonMapper", JACKSON3_PACKAGE + ".databind.json.JsonMapper"); } public void setSerializationLibrary(String serializationLibrary) { diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java index d0b94b240f30..825ff532da51 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java @@ -54,8 +54,7 @@ import java.util.stream.Collectors; import static org.apache.commons.lang3.StringUtils.isNotEmpty; -import static org.openapitools.codegen.CodegenConstants.USE_DEDUCTION_FOR_ONE_OF_INTERFACES; -import static org.openapitools.codegen.CodegenConstants.USE_DEDUCTION_FOR_ONE_OF_INTERFACES_DESC; +import static org.openapitools.codegen.CodegenConstants.*; import static org.openapitools.codegen.utils.CamelizeOption.LOWERCASE_FIRST_LETTER; import static org.openapitools.codegen.utils.StringUtils.camelize; @@ -114,9 +113,6 @@ public class SpringCodegen extends AbstractJavaCodegen public static final String USE_SPRING_BUILT_IN_VALIDATION = "useSpringBuiltInValidation"; public static final String SPRING_API_VERSION = "springApiVersion"; public static final String USE_JACKSON_3 = "useJackson3"; - public static final String JACKSON2_PACKAGE = "com.fasterxml.jackson"; - public static final String JACKSON3_PACKAGE = "tools.jackson"; - public static final String JACKSON_PACKAGE = "jacksonPackage"; public static final String ADDITIONAL_NOT_NULL_ANNOTATIONS = "additionalNotNullAnnotations"; public static final String AUTO_X_SPRING_PAGINATED = "autoXSpringPaginated"; public static final String GENERATE_SORT_VALIDATION = "generateSortValidation"; @@ -344,6 +340,7 @@ public SpringCodegen() { .defaultValue("false") ); cliOptions.add(CliOption.newBoolean(USE_JSPECIFY, "Use Jspecify for null checks", useJspecify)); + supportedLibraries.put(SPRING_BOOT, "Spring-boot Server application."); supportedLibraries.put(SPRING_CLOUD_LIBRARY, "Spring-Cloud-Feign client with Spring-Boot auto-configured settings."); @@ -606,6 +603,9 @@ public void processOpts() { importMapping.put("org.springframework.core.io.Resource", "org.springframework.core.io.Resource"); importMapping.put("DateTimeFormat", "org.springframework.format.annotation.DateTimeFormat"); importMapping.put("ParameterObject", "org.springdoc.api.annotations.ParameterObject"); + String jacksonPackage = (String)additionalProperties.get("jacksonPackage"); + importMapping.put("JsonMapper", jacksonPackage + ".databind.json.JsonMapper"); + if (isUseSpringBoot3() || isUseSpringBoot4()) { importMapping.put("ParameterObject", "org.springdoc.core.annotations.ParameterObject"); } diff --git a/modules/openapi-generator/src/main/resources/Java/jacksonMixinConfig.mustache b/modules/openapi-generator/src/main/resources/Java/jacksonMixinConfig.mustache new file mode 100644 index 000000000000..ecff7f73998d --- /dev/null +++ b/modules/openapi-generator/src/main/resources/Java/jacksonMixinConfig.mustache @@ -0,0 +1,45 @@ +package {{invokerPackage}}; +{{#vendorExtensions.x-jacksonMixinConfig}} + +{{^useJackson3}} +import {{jacksonPackage}}.databind.ObjectMapper; +{{/useJackson3}} +import {{jacksonPackage}}.databind.json.JsonMapper; +{{#mixins}} +import {{modelPackage}}.{{.}}; +{{/mixins}} + +public class JacksonMixinConfig { + private static volatile {{mapper}} INSTANCE; + + /** + Get the {{mapper}} used by the @JsonCreator in @JsonUnWrapped interfaces. + */ + public static {{mapper}} getMapper() { + if (INSTANCE == null) { + setBuilder({{^useJackson3}}JsonMapper.builder().findAndAddModules(){{/useJackson3}}{{#useJackson3}}JsonMapper.shared().rebuild(){{/useJackson3}}); + } + return INSTANCE; + } + + /** + Initialize the mapper used by the @JsonCreator. +

+ Do not pass the global {{mapper}} as @JsonUnwrapped does not support deserializer. + */ + public static void set{{mapper}}({{mapper}} mapper) { + INSTANCE = mapper; + } + + /** + Initialize the mapper used by the @JsonCreator. +

+ Configure the Mixins + */ + public static void setBuilder(JsonMapper.Builder jsonMapperBuilder) { + INSTANCE = jsonMapperBuilder{{#mixins}} + .addMixIn({{.}}.class, {{.}}.{{.}}Mixin.class){{/mixins}} + .build(); + } + {{/vendorExtensions.x-jacksonMixinConfig}} +} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/Java/oneof_interface.mustache b/modules/openapi-generator/src/main/resources/Java/oneof_interface.mustache index 742de98954c7..33b2c64efb42 100644 --- a/modules/openapi-generator/src/main/resources/Java/oneof_interface.mustache +++ b/modules/openapi-generator/src/main/resources/Java/oneof_interface.mustache @@ -6,4 +6,21 @@ public {{>sealed}}interface {{classname}} {{#vendorExtensions.x-implements}}{{#- {{#discriminator}} public {{propertyType}} {{propertyGetter}}(); {{/discriminator}} + {{#vendorExtensions.x-oneof-jsonCreator}} + + @JsonCreator + static {{classname}} valueOf(JsonNode node) {{^useJackson3}}throws JsonProcessingException {{/useJackson3}}{ + return JacksonMixinConfig.getMapper().treeToValue(node, {{classname}}.class); + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) + @JsonSubTypes({ + {{#interfaceModels}} + @JsonSubTypes.Type(value = {{classname}}.class){{^-last}}, {{/-last}} + {{/interfaceModels}} + }) + static interface {{classname}}Mixin { + + } + {{/vendorExtensions.x-oneof-jsonCreator}} } diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/jacksonMixinConfig.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/jacksonMixinConfig.mustache new file mode 100644 index 000000000000..d42bd0752be1 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/JavaSpring/jacksonMixinConfig.mustache @@ -0,0 +1,46 @@ +package {{configPackage}}; +{{#vendorExtensions.x-jacksonMixinConfig}} + +{{^useJackson3}} +import {{jacksonPackage}}.databind.ObjectMapper; +{{/useJackson3}} +import {{jacksonPackage}}.databind.json.JsonMapper; +{{#mixins}} +import {{modelPackage}}.{{.}}; +{{/mixins}} + +public class JacksonMixinConfig { + + private static volatile {{mapper}} INSTANCE; + + /** + Get the {{vmapper}} used by the @JsonCreator in @JsonUnWrapped interfaces. + */ + public static {{mapper}} getMapper() { + if (INSTANCE == null) { + setBuilder({{^useJackson3}}JsonMapper.builder().findAndAddModules(){{/useJackson3}}{{#useJackson3}}JsonMapper.shared().rebuild(){{/useJackson3}}); + } + return INSTANCE; + } + + /** + Initialize the mapper used by the @JsonCreator. +

+ Do not pass the global {{mapper}} as @JsonUnwrapped does not support deserializer. + */ + public static void set{{mapper}}({{mapper}} mapper) { + INSTANCE = mapper; + } + + /** + Initialize the mapper used by the @JsonCreator. +

+ Configure the Mixins + */ + public static void setBuilder(JsonMapper.Builder jsonMapperBuilder) { + INSTANCE = jsonMapperBuilder{{#mixins}} + .addMixIn({{.}}.class, {{.}}.{{.}}Mixin.class){{/mixins}} + .build(); + } +} +{{/vendorExtensions.x-jacksonMixinConfig}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/oneof_interface.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/oneof_interface.mustache index 00fca619c748..288b2ef0c625 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/oneof_interface.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/oneof_interface.mustache @@ -6,14 +6,14 @@ {{#discriminator}} {{>typeInfoAnnotation}} -{{/discriminator}}{{^discriminator}}{{#useDeductionForOneOfInterfaces}} +{{/discriminator}}{{^discriminator}}{{#useDeductionForOneOfInterfaces}}{{^vendorExtensions.x-oneof-jsonCreator}} @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) @JsonSubTypes({ {{#interfaceModels}} @JsonSubTypes.Type(value = {{classname}}.class){{^-last}}, {{/-last}} {{/interfaceModels}} }) -{{/useDeductionForOneOfInterfaces}}{{#vendorExtensions.x-class-extra-annotation}}{{{vendorExtensions.x-class-extra-annotation}}} +{{/vendorExtensions.x-oneof-jsonCreator}}{{/useDeductionForOneOfInterfaces}}{{#vendorExtensions.x-class-extra-annotation}}{{{vendorExtensions.x-class-extra-annotation}}} {{/vendorExtensions.x-class-extra-annotation}} {{/discriminator}} {{>generatedAnnotation}} @@ -22,4 +22,21 @@ public {{>sealed}}interface {{classname}}{{#vendorExtensions.x-implements}}{{#-f {{#discriminator}} public {{propertyType}} {{propertyGetter}}(); {{/discriminator}} + {{#vendorExtensions.x-oneof-jsonCreator}} + + @JsonCreator + static {{classname}} valueOf(JsonNode node) {{^useJackson3}}throws JsonProcessingException {{/useJackson3}}{ + return JacksonMixinConfig.getMapper().treeToValue(node, {{classname}}.class); + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) + @JsonSubTypes({ + {{#interfaceModels}} + @JsonSubTypes.Type(value = {{classname}}.class){{^-last}}, {{/-last}} + {{/interfaceModels}} + }) + static interface {{classname}}Mixin { + + } + {{/vendorExtensions.x-oneof-jsonCreator}} } \ No newline at end of file diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaClientCodegenTest.java index d34e05dfdfd2..aca77b7cf086 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaClientCodegenTest.java @@ -4452,6 +4452,30 @@ void oneOf_issue_912() { .recursivelyContainsWithNameAndAttributes("JsonSubTypes.Type", Map.of("value", "Source.class", "name", "\"source\"")); } + @Test + void unwrapped_oneOf_with_inheritance_sb3() throws IOException { + final Map files = generateFromContract("src/test/resources/3_0/oneOf_unwrap_mixed.yaml", RESTCLIENT, + Map.of(AbstractJavaCodegen.USE_ONE_OF_INTERFACES, true, + USE_SPRING_BOOT4, false), + configurator -> configurator.setOpenapiNormalizer(Map.of("USE_UNWRAPPED_FOR_COMPOSITE_ONEOF", "true"))); + + JavaFileAssert.assertThat(files.get("Account.java")) + .assertProperty("oneOf") + .doesImportAnnotation("JsonUnwrapped") + .assertPropertyAnnotations().containsWithName("JsonUnwrapped"); + + JavaFileAssert.assertThat(files.get("AccountOneOf.java")) + .assertTypeAnnotations().doesNotContainWithName("JsonSubTypes").toType() + .fileContains("static interface AccountOneOfMixin", "@JsonCreator") + .hasImports("com.fasterxml.jackson.databind.JsonNode"); + JavaFileAssert.assertThat(files.get("JacksonMixinConfig.java")) + .fileContains(".addMixIn(AccountOneOf.class, AccountOneOf.AccountOneOfMixin.class)", + ".addMixIn(BankAllOfOneOf.class, BankAllOfOneOf.BankAllOfOneOfMixin.class)") + .hasImports("org.openapitools.client.model.AccountOneOf", + "org.openapitools.client.model.BankAllOfOneOf", + "com.fasterxml.jackson.databind.ObjectMapper", + "com.fasterxml.jackson.databind.json.JsonMapper"); + } @Test public void testUseDeductionForOneInterfaces() { final Map files = generateFromContract("src/test/resources/3_1/oneof_polymorphism_and_inheritance.yaml", RESTCLIENT, diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/assertions/AbstractAnnotationsAssert.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/assertions/AbstractAnnotationsAssert.java index 22c1027aef25..5a94fcd0b72b 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/assertions/AbstractAnnotationsAssert.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/assertions/AbstractAnnotationsAssert.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.regex.Pattern; import java.util.stream.Collectors; @CanIgnoreReturnValue @@ -149,4 +150,7 @@ private boolean containsSpecificAnnotationNameAndAttributes(Node node, String na return false; } + + + } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/assertions/PropertyAssert.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/assertions/PropertyAssert.java index e598c210a1f6..04b7ff68baf3 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/assertions/PropertyAssert.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/assertions/PropertyAssert.java @@ -5,6 +5,8 @@ import org.assertj.core.api.ObjectAssert; import org.assertj.core.util.CanIgnoreReturnValue; +import java.util.regex.Pattern; + @CanIgnoreReturnValue public class PropertyAssert extends ObjectAssert { @@ -43,4 +45,32 @@ public PropertyAnnotationsAssert hasAnnotation(String annotationName) { actual.getAnnotations() ).containsWithName(annotationName); } + + /** + * assert that the annotation is not specifed in an import. + * + * @param name classname of the annotation. For example "Nullable" or a full qualified class name like "java.util.List" + */ + public PropertyAssert doesNotImportAnnotation(final String name) { + String pattern = "import\\s+" + + (name.contains(".")?"" : "[\\w.]+\\.") + + Pattern.quote(name) + ";"; + this.toType().fileDoesNotContainPattern(pattern); + return this; + } + + /** + * assert that the annotation is imported. + * + * @param name clasname of the annotation. For example "Nullable" or a full qualified class name like "java.util.List" + */ + public PropertyAssert doesImportAnnotation(final String name) { + String pattern = "import\\s+" + + (name.contains(".")?"" : "[\\w.]+\\.") + + Pattern.quote(name) + ";"; + this.toType().fileContainsPattern(pattern); + return this; + } + + } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index da1e4aefface..ef746076b2ce 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java @@ -7725,4 +7725,84 @@ void disableDiscriminatorJsonIgnorePropertiesIsTrueThenJsonIgnorePropertiesShoul JavaFileAssert.assertThat(files.get("BaseConfiguration.java")) .assertTypeAnnotations().containsWithName("JsonIgnoreProperties"); } + + @Test + void unwrapped_oneOf_with_inheritance_sb4() throws IOException { + final Map files = generateFromContract("src/test/resources/3_0/oneOf_unwrap_mixed.yaml", SPRING_BOOT, + Map.of(AbstractJavaCodegen.USE_ONE_OF_INTERFACES, true, + USE_DEDUCTION_FOR_ONE_OF_INTERFACES, true, + USE_SPRING_BOOT4, true, + USE_JACKSON_3, true), + configurator -> configurator.setOpenapiNormalizer(Map.of("USE_UNWRAPPED_FOR_COMPOSITE_ONEOF", "true"))); + + JavaFileAssert.assertThat(files.get("Account.java")) + .assertProperty("oneOf") + .doesImportAnnotation("JsonUnwrapped") + .assertPropertyAnnotations().containsWithName("JsonUnwrapped"); + + JavaFileAssert.assertThat(files.get("AccountOneOf.java")) + .isInterface() + .assertTypeAnnotations().doesNotContainWithName("JsonSubTypes").toType() + .fileContains("static interface AccountOneOfMixin", "@JsonCreator", + "@JsonSubTypes.Type(value = LegacyBankNumber.class)", "@JsonSubTypes.Type(value = WireTransferInfo.class)") + .hasImports("tools.jackson.databind.JsonNode"); + JavaFileAssert.assertThat(files.get("JacksonMixinConfig.java")) + .fileContains(".addMixIn(AccountOneOf.class, AccountOneOf.AccountOneOfMixin.class)", + ".addMixIn(BankAllOfOneOf.class, BankAllOfOneOf.BankAllOfOneOfMixin.class)") + .hasImports("org.openapitools.model.AccountOneOf", + "org.openapitools.model.BankAllOfOneOf", + "tools.jackson.databind.json.JsonMapper"); + JavaFileAssert.assertThat(files.get("Other.java")) + .fileDoesNotContain("JsonUnwrapped"); + } + + @Test + void unwrapped_oneOf_with_inheritance_sb3() throws IOException { + final Map files = generateFromContract("src/test/resources/3_0/oneOf_unwrap_mixed.yaml", SPRING_BOOT, + Map.of(AbstractJavaCodegen.USE_ONE_OF_INTERFACES, true, + USE_DEDUCTION_FOR_ONE_OF_INTERFACES, true, + USE_SPRING_BOOT3, true) + , + configurator -> configurator.setOpenapiNormalizer(Map.of("USE_UNWRAPPED_FOR_COMPOSITE_ONEOF", "true"))); + + JavaFileAssert.assertThat(files.get("Account.java")) + .assertProperty("oneOf") + .doesImportAnnotation("JsonUnwrapped") + .assertPropertyAnnotations().containsWithName("JsonUnwrapped"); + + JavaFileAssert.assertThat(files.get("AccountOneOf.java")) + .isInterface() + .assertTypeAnnotations().doesNotContainWithName("JsonSubTypes").toType() + .fileContains("static interface AccountOneOfMixin", "@JsonCreator") + .hasImports("com.fasterxml.jackson.databind.JsonNode"); + JavaFileAssert.assertThat(files.get("JacksonMixinConfig.java")) + .fileContains(".addMixIn(AccountOneOf.class, AccountOneOf.AccountOneOfMixin.class)", + ".addMixIn(BankAllOfOneOf.class, BankAllOfOneOf.BankAllOfOneOfMixin.class)") + .hasImports("org.openapitools.model.AccountOneOf", + "org.openapitools.model.BankAllOfOneOf", + "com.fasterxml.jackson.databind.ObjectMapper", + "com.fasterxml.jackson.databind.json.JsonMapper"); + } + + @Test + void unwrapped_oneOf_with_composition() throws IOException { + final Map files = generateFromContract("src/test/resources/3_0/oneOf_unwrap_mixed.yaml", SPRING_BOOT, + Map.of(AbstractJavaCodegen.USE_ONE_OF_INTERFACES, false, + USE_DEDUCTION_FOR_ONE_OF_INTERFACES, false, + USE_SPRING_BOOT4, true, + USE_JACKSON_3, true), + configurator -> configurator.setOpenapiNormalizer(Map.of("USE_UNWRAPPED_FOR_COMPOSITE_ONEOF", "true"))); + JavaFileAssert.assertThat(files.get("Account.java")) + .assertProperty("oneOf") + .doesImportAnnotation("JsonUnwrapped") + .assertPropertyAnnotations().containsWithName("JsonUnwrapped"); + JavaFileAssert.assertThat(files.get("AccountOneOf.java")) + .isNormalClass() + .assertProperty("bankNumber").toType() + .assertProperty("iban").toType() + .assertProperty("bic").toType() + .assertTypeAnnotations().doesNotContainWithName("JsonSubTypes").toType() + .fileDoesNotContain("Mixin", "@JsonCreator"); + + } } diff --git a/modules/openapi-generator/src/test/resources/3_0/oneOf_unwrap_mixed.yaml b/modules/openapi-generator/src/test/resources/3_0/oneOf_unwrap_mixed.yaml new file mode 100644 index 000000000000..2edfa98e330e --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/oneOf_unwrap_mixed.yaml @@ -0,0 +1,68 @@ +openapi: 3.0.3 +info: + version: "1.0.0" + title: test inline enum +paths: + /test: + post: + requestBody: + description: request + content: + application/json: + schema: + $ref: '#/components/schemas/Account' + required: true + responses: + 200: + description: ok +components: + schemas: + Account: + type: object + properties: + owner: + type: string + bank: + $ref: '#/components/schemas/Bank' + oneOf: + - $ref: '#/components/schemas/LegacyBankNumber' + - $ref: '#/components/schemas/WireTransferInfo' + LegacyBankNumber: + properties: + bankNumber: + type: string + WireTransferInfo: + properties: + iban: + type: string + bic: + type: string + Bank: + allOf: + - properties: + name: + type: string + oneOf: + - properties: + countryAlpha2: + type: string + - properties: + countryAlpha3: + type: string + Other: + properties: + type: + type: string + discriminator: + propertyName: type + oneOf: + - $ref: '#/components/schemas/OtherA' + - $ref: '#/components/schemas/OtherB' + OtherA: + properties: + a: + type: string + OtherB: + properties: + b: + type: string \ No newline at end of file