Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,47 @@ index 6f27abd..146c61c 100644
type: string
```

- `USE_UNWRAPPED_FOR_INLINE_ONEOF` set to true to unwrap inline oneOf combined with allOf/properties and without discriminator (aka mixed 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 X_ONE_OF_UNWRAPPED=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:
- - properties:
- bankNumber:
- type: string
- bic:
- type: string
- - properties:
- iban:
- type: string
properties:
bank:
$ref: '#/components/schemas/Bank'
+ oneOf:
+ X_ONE_OF_UNWRAPPED: true
+ oneOf:
+ - properties:
+ bankNumber:
+ type: string
+ bic:
+ type: string
+ - properties:
+ iban:
+ type: string
```

- `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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -499,4 +499,7 @@ public static enum ENUM_PROPERTY_NAMING_TYPE {camelCase, PascalCase, snake_case,
public static final String X_DISCRIMINATOR_VALUE = "x-discriminator-value";
public static final String X_ONE_OF_NAME = "x-one-of-name";
public static final String X_NULLABLE = "x-nullable";

public static final String X_ONE_OF_JSON_CREATOR = "x-oneof-jsonCreator";
public static final String X_ONE_OF_UNWRAPPED = "x-oneOfunwrapper";
}
Original file line number Diff line number Diff line change
Expand Up @@ -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_INLINE_ONEOF = "USE_UNWRAPPED_FOR_INLINE_ONEOF";
// ============= end of rules =============

/**
Expand Down Expand Up @@ -219,6 +220,7 @@ public OpenAPINormalizer(OpenAPI openAPI, Map<String, String> 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_INLINE_ONEOF);

// rules that are default to true
rules.put(SIMPLIFY_ONEOF_ANYOF, true);
Expand Down Expand Up @@ -770,7 +772,7 @@ public Schema normalizeSchema(Schema schema, Set<Schema> visitedSchemas) {
}

if (ModelUtils.hasAllOf(schema)) {
return normalizeAllOf(schema, visitedSchemas);
schema = normalizeAllOf(schema, visitedSchemas);
}

if (ModelUtils.hasOneOf(schema)) {
Expand Down Expand Up @@ -1079,6 +1081,8 @@ protected Schema normalizeOneOf(Schema schema, Set<Schema> visitedSchemas) {
schema.getOneOf().set(i, normalizeSchema((Schema) item, visitedSchemas));
}
schema = processReplaceOneOfByMapping(schema);
schema = processUnwrappedOneOf(schema);

} else {
// normalize it as it's no longer an oneOf
schema = normalizeSchema(schema, visitedSchemas);
Expand All @@ -1087,6 +1091,49 @@ protected Schema normalizeOneOf(Schema schema, Set<Schema> visitedSchemas) {
return schema;
}

protected Schema processUnwrappedOneOf(Schema schema) {
if (!getRule(USE_UNWRAPPED_FOR_INLINE_ONEOF)) {
return schema;
}
if (!(ModelUtils.hasOneOf(schema) && (ModelUtils.hasProperties(schema) || ModelUtils.hasAllOf(schema)))) {
return schema;
}

// 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<Schema> oneOfs = schema.getOneOf();
if (oneOfs.stream().allMatch(oneOf -> oneOf.get$ref() != null)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: NullPointerException in processUnwrapCompositeOneOf when oneOf list contains null elements. normalizeOneOf explicitly tolerates null entries (if (item == null) continue;) but processUnwrapCompositeOneOf's stream allMatch dereferences elements without null checking (oneOf.get$ref()), causing an NPE.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java, line 1108:

<comment>NullPointerException in processUnwrapCompositeOneOf when oneOf list contains null elements. normalizeOneOf explicitly tolerates null entries (`if (item == null) continue;`) but processUnwrapCompositeOneOf's stream allMatch dereferences elements without null checking (`oneOf.get$ref()`), causing an NPE.</comment>

<file context>
@@ -1087,6 +1091,49 @@ protected Schema normalizeOneOf(Schema schema, Set<Schema> visitedSchemas) {
+        boolean hasDiscriminator = schema.getDiscriminator() != null;
+        if (hasDiscriminator) {
+            List<Schema> oneOfs = schema.getOneOf();
+            if (oneOfs.stream().allMatch(oneOf -> oneOf.get$ref() != null)) {
+                // skip normalization if discriminator but not maping
+                if (discriminator.getMapping() == null && discriminator.getPropertyName() != null) {
</file context>
Suggested change
if (oneOfs.stream().allMatch(oneOf -> oneOf.get$ref() != null)) {
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 schema;
}
// if same size for mapping and oneOf, assume that we skip this normalization
if (discriminator.getMapping() != null && discriminator.getMapping().size() == schema.getOneOf().size()) {
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";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Synthetic oneOf property can silently overwrite an existing user-defined oneOf property

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java, line 1125:

<comment>Synthetic `oneOf` property can silently overwrite an existing user-defined `oneOf` property</comment>

<file context>
@@ -1087,6 +1091,49 @@ protected Schema normalizeOneOf(Schema schema, Set<Schema> visitedSchemas) {
+        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);
</file context>

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;
}

protected Schema normalizeAnyOf(Schema schema, Set<Schema> visitedSchemas) {
//transform anyOf into enums if needed
schema = processSimplifyAnyOfEnum(schema);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, String> schemaKeyToModelNameCache = new HashMap<>();

Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2668,7 +2672,7 @@ public List<VendorExtension> 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;
}

Expand Down Expand Up @@ -2813,4 +2817,70 @@ protected void addNullableImportForOperation(CodegenOperation codegenOperation)
.findAny()
.ifPresent(param -> codegenOperation.imports.add("Nullable"));
}

@Override
public Map<String, ModelsMap> updateAllModels(Map<String, ModelsMap> 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.
* <p>
* Add the necessary imports.
* <p>
* Construct a vendorExtension X_ONE_OF_JSON_CREATOR with a map containing:
* <ol>
* <li>>mapper: JsonMapper or ObjectMapper
* <li>mixins: a list with the class name of the oneOf classes
* </ol>
*/
protected void addOneOfMixinSupport(ModelsMap obj, CodegenModel cm) {
String configPackage = getConfigPackage();
Map<String, Object> 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<String, Object>)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", importMapping.get("JsonNode")));
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
Comment thread
jpfinne marked this conversation as resolved.
Outdated
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1322,10 +1322,14 @@ public void setCaseInsensitiveResponseHeaders(final Boolean caseInsensitiveRespo

protected void applyJackson2Package() {
writePropertyBack(JACKSON_PACKAGE, JACKSON2_PACKAGE);
importMapping.put("JsonNode", JACKSON2_PACKAGE + ".databind.JsonNode");
importMapping.put("JsonMapper", JACKSON2_PACKAGE + ".databind.json.JsonMapper");
}

protected void applyJackson3Package() {
writePropertyBack(JACKSON_PACKAGE, JACKSON3_PACKAGE);
importMapping.put("JsonNode", JACKSON3_PACKAGE + ".databind.JsonNode");
importMapping.put("JsonMapper", JACKSON3_PACKAGE + ".databind.json.JsonMapper");
}

public void setSerializationLibrary(String serializationLibrary) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,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.");
Expand Down Expand Up @@ -605,6 +606,10 @@ 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("JsonNode", jacksonPackage + ".databind.JsonNode");
importMapping.put("JsonMapper", jacksonPackage + ".databind.json.JsonMapper");

if (isUseSpringBoot3() || isUseSpringBoot4()) {
importMapping.put("ParameterObject", "org.springdoc.core.annotations.ParameterObject");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package {{invokerPackage}};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Unsynchronized lazy initialization can overwrite a concurrently supplied custom mapper and make mapper configuration nondeterministic.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/Java/jacksonMixinConfig.mustache, line 19:

<comment>Unsynchronized lazy initialization can overwrite a concurrently supplied custom mapper and make mapper configuration nondeterministic.</comment>

<file context>
@@ -0,0 +1,44 @@
+      Get the {{vendorExtensions.x-jackson-mixins-mapper}} used by the @JsonCreator in @JsonUnWrapped interfaces.
+    */
+    public static {{vendorExtensions.x-jackson-mixins-mapper}} getMapper() {
+      if (INSTANCE == null) {
+        setBuilder({{^useJackson3}}JsonMapper.builder().findAndAddModules(){{/useJackson3}}{{#useJackson3}}JsonMapper.shared().rebuild(){{/useJackson3}});
+      }
</file context>

{{#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.
<p>
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.
<p>
Configure the Mixins
*/
public static void setBuilder(JsonMapper.Builder jsonMapperBuilder) {
INSTANCE = jsonMapperBuilder{{#mixins}}
.addMixIn({{.}}.class, {{.}}.{{.}}Mixin.class){{/mixins}}
.build();
}
{{/vendorExtensions.x-jacksonMixinConfig}}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Class closing brace is outside the conditional Mustache section, producing invalid Java if the section is omitted

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/Java/jacksonMixinConfig.mustache, line 44:

<comment>Class closing brace is outside the conditional Mustache section, producing invalid Java if the section is omitted</comment>

<file context>
@@ -0,0 +1,45 @@
+          .addMixIn({{.}}.class, {{.}}.{{.}}Mixin.class){{/mixins}}
+          .build();
+    }
+ {{/vendorExtensions.x-jacksonMixinConfig}}
+}
\ No newline at end of file
</file context>

}
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
}
Loading
Loading