diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java index d83cdef420cf..69158d944c90 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java @@ -2794,7 +2794,12 @@ protected void updateModelForComposedSchema(CodegenModel m, Schema schema, Map newProperties = new LinkedHashMap<>(); + addProperties(newProperties, required, refSchema, new HashSet<>()); + mergeProperties(properties, newProperties); + addProperties(allProperties, allRequired, refSchema, new HashSet<>()); + } else if (allParents.contains(ref) && supportsMultipleInheritance) { // multiple inheritance addProperties(allProperties, allRequired, refSchema, new HashSet<>()); } else if (parentName != null && parentName.equals(ref) && supportsInheritance) { @@ -3156,6 +3161,31 @@ public CodegenModel fromModel(String name, Schema schema) { // remove duplicated properties m.removeAllDuplicatedProperty(); + if (m.parent != null && m.readOnlyVars != null) { + Schema parentSchema = null; + if (allDefinitions != null && m.parentSchema != null) { + parentSchema = allDefinitions.get(m.parentSchema); + } + + Map parentProperties = new LinkedHashMap<>(); + if (parentSchema != null) { + addProperties(parentProperties, new ArrayList<>(), parentSchema, new HashSet<>()); + } + + Set parentReadOnlyNames = new HashSet<>(); + for (Map.Entry entry : parentProperties.entrySet()) { + if (Boolean.TRUE.equals(entry.getValue().getReadOnly())) { + parentReadOnlyNames.add(entry.getKey()); + } + } + + for (CodegenProperty roVar : m.readOnlyVars) { + if (Boolean.TRUE.equals(roVar.isOverridden) || parentReadOnlyNames.contains(roVar.baseName)) { + roVar.vendorExtensions.put("x-is-inherited-readonly", Boolean.TRUE); + } + } + } + // set isDiscriminator on the discriminator property if (m.discriminator != null) { String discPropName = m.discriminator.getPropertyBaseName(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index a8f62b1b8331..a59d13e1ee16 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -1584,6 +1584,10 @@ public static String getParentName(Schema composedSchema, Map al if (s == null) { LOGGER.error("Failed to obtain schema from {}", parentName); parentNameCandidates.add("UNKNOWN_PARENT_NAME"); + } else if (isOneOfWrapperSchema(s)) { + // Skip oneOf wrapper schemas (generate AbstractOpenApiSchema subclasses) + hasAmbiguousParents = true; + refedWithoutDiscriminator.add(parentName); } else if (hasOrInheritsDiscriminator(s, allSchemas, new ArrayList())) { // discriminator.propertyName is used or x-parent is used parentNameCandidates.add(parentName); @@ -1650,6 +1654,8 @@ private static List getAllParentsName( if (s == null) { LOGGER.error("Failed to obtain schema from {}", parentName); names.add("UNKNOWN_PARENT_NAME"); + } else if (isOneOfWrapperSchema(s)) { + // Skip oneOf wrapper schemas - properties will be inlined } else if (hasOrInheritsDiscriminator(s, allSchemas, new ArrayList())) { // discriminator.propertyName is used or x-parent is used names.add(parentName); @@ -2122,6 +2128,24 @@ public static boolean hasOneOf(Schema schema) { return false; } + /** + * Returns true if the schema is a oneOf wrapper (has oneOf + properties/discriminator). + * Such schemas generate AbstractOpenApiSchema subclasses and cannot be used as parents. + * + * @param schema the schema + * @return true if the schema is a oneOf wrapper + */ + public static boolean isOneOfWrapperSchema(Schema schema) { + if (schema == null) { + return false; + } + boolean hasOneOf = schema.getOneOf() != null && !schema.getOneOf().isEmpty(); + boolean hasDiscriminator = schema.getDiscriminator() != null && + schema.getDiscriminator().getPropertyName() != null; + boolean hasProperties = schema.getProperties() != null && !schema.getProperties().isEmpty(); + return hasOneOf && (hasDiscriminator || hasProperties); + } + /** * Returns true if the schema contains anyOf but * no properties/allOf/anyOf defined. diff --git a/modules/openapi-generator/src/main/resources/Java/libraries/native/pojo.mustache b/modules/openapi-generator/src/main/resources/Java/libraries/native/pojo.mustache index abdf16d12009..56e16c8e6b51 100644 --- a/modules/openapi-generator/src/main/resources/Java/libraries/native/pojo.mustache +++ b/modules/openapi-generator/src/main/resources/Java/libraries/native/pojo.mustache @@ -95,7 +95,7 @@ public class {{classname}} {{#parent}}extends {{{.}}} {{/parent}}{{#vendorExtens ) { this(); {{#readOnlyVars}} - this.{{name}} = {{#vendorExtensions.x-is-jackson-optional-nullable}}{{name}} == null ? JsonNullable.<{{{datatypeWithEnum}}}>undefined() : JsonNullable.of({{name}}){{/vendorExtensions.x-is-jackson-optional-nullable}}{{^vendorExtensions.x-is-jackson-optional-nullable}}{{name}}{{/vendorExtensions.x-is-jackson-optional-nullable}}; + {{#vendorExtensions.x-is-inherited-readonly}}this.{{setter}}({{name}});{{/vendorExtensions.x-is-inherited-readonly}}{{^vendorExtensions.x-is-inherited-readonly}}this.{{name}} = {{#vendorExtensions.x-is-jackson-optional-nullable}}{{name}} == null ? JsonNullable.<{{{datatypeWithEnum}}}>undefined() : JsonNullable.of({{name}}){{/vendorExtensions.x-is-jackson-optional-nullable}}{{^vendorExtensions.x-is-jackson-optional-nullable}}{{name}}{{/vendorExtensions.x-is-jackson-optional-nullable}};{{/vendorExtensions.x-is-inherited-readonly}} {{/readOnlyVars}} }{{/withXml}}{{/vendorExtensions.x-has-readonly-properties}} {{#vars}} @@ -254,12 +254,25 @@ public class {{classname}} {{#parent}}extends {{{.}}} {{/parent}}{{#vendorExtens {{/vendorExtensions.x-is-jackson-optional-nullable}} } {{/isReadOnly}} + {{#isReadOnly}} + /** + * Protected setter for {{name}} (readOnly property, used by subclasses' @JsonCreator). + */ + protected void {{setter}}({{>nullable_var_annotations}} {{{datatypeWithEnum}}} {{name}}) { + {{#vendorExtensions.x-is-jackson-optional-nullable}} + this.{{name}} = {{name}} == null ? JsonNullable.<{{{datatypeWithEnum}}}>undefined() : JsonNullable.of({{name}}); + {{/vendorExtensions.x-is-jackson-optional-nullable}} + {{^vendorExtensions.x-is-jackson-optional-nullable}} + this.{{name}} = {{name}}; + {{/vendorExtensions.x-is-jackson-optional-nullable}} + } + {{/isReadOnly}} {{/vars}} {{>libraries/native/additional_properties}} {{#parent}} - {{#allVars}} + {{#readWriteVars}} {{#isOverridden}} @Override public {{classname}} {{name}}({{>nullable_var_annotations}} {{{datatypeWithEnum}}} {{name}}) { @@ -273,7 +286,7 @@ public class {{classname}} {{#parent}}extends {{{.}}} {{/parent}}{{#vendorExtens } {{/isOverridden}} - {{/allVars}} + {{/readWriteVars}} {{/parent}} /** * Return true if this {{name}} object is equal to o. diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java index 32eee9f1969a..a1b904d88538 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java @@ -5074,4 +5074,69 @@ private List getNames(List props) { if (props == null) return null; return props.stream().map(v -> v.name).collect(Collectors.toList()); } + + @Test + public void testOneOfWrapperSchemaShouldNotBeUsedAsParent() { + final DefaultCodegen codegen = new DefaultCodegen(); + final OpenAPI openAPI = TestUtils.parseFlattenSpec("src/test/resources/3_0/issue-oneOf-wrapper-inheritance.yaml"); + codegen.setOpenAPI(openAPI); + + Schema clickEventSchema = openAPI.getComponents().getSchemas().get("ClickEvent"); + CodegenModel clickEventModel = codegen.fromModel("ClickEvent", clickEventSchema); + + assertNull(clickEventModel.parent, "ClickEvent should not have EventWrapper as parent"); + + Set clickEventVarNames = clickEventModel.vars.stream() + .map(v -> v.name) + .collect(Collectors.toSet()); + assertTrue(clickEventVarNames.contains("timestamp"), "ClickEvent should have 'timestamp' inlined"); + assertTrue(clickEventVarNames.contains("userId"), "ClickEvent should have 'userId' inlined"); + assertTrue(clickEventVarNames.contains("sessionId"), "ClickEvent should have 'sessionId' inlined"); + assertTrue(clickEventVarNames.contains("elementId"), "ClickEvent should have 'elementId'"); + assertTrue(clickEventVarNames.contains("clickX"), "ClickEvent should have 'clickX'"); + assertTrue(clickEventVarNames.contains("clickY"), "ClickEvent should have 'clickY'"); + } + + @Test + public void testIsOneOfWrapperSchema() { + final OpenAPI openAPI = TestUtils.parseFlattenSpec("src/test/resources/3_0/issue-oneOf-wrapper-inheritance.yaml"); + + Schema eventWrapperSchema = openAPI.getComponents().getSchemas().get("EventWrapper"); + assertTrue(ModelUtils.isOneOfWrapperSchema(eventWrapperSchema), + "EventWrapper should be detected as oneOf wrapper"); + + Schema clickEventSchema = openAPI.getComponents().getSchemas().get("ClickEvent"); + assertFalse(ModelUtils.isOneOfWrapperSchema(clickEventSchema), + "ClickEvent should not be detected as oneOf wrapper"); + + Schema scrollEventSchema = openAPI.getComponents().getSchemas().get("ScrollEvent"); + assertFalse(ModelUtils.isOneOfWrapperSchema(scrollEventSchema), + "ScrollEvent should not be detected as oneOf wrapper"); + } + + @Test + public void testInheritedReadOnlyPropertiesHaveProtectedSetters() { + final DefaultCodegen codegen = new DefaultCodegen(); + final OpenAPI openAPI = TestUtils.parseFlattenSpec("src/test/resources/3_0/issue-inherited-readonly-jsoncreator.yaml"); + codegen.setOpenAPI(openAPI); + + Schema baseModelSchema = openAPI.getComponents().getSchemas().get("BaseModel"); + CodegenModel baseModel = codegen.fromModel("BaseModel", baseModelSchema); + + assertTrue(baseModel.readOnlyVars != null && !baseModel.readOnlyVars.isEmpty(), + "BaseModel should have readOnly properties"); + + Schema extendedModelSchema = openAPI.getComponents().getSchemas().get("ExtendedModel"); + CodegenModel extendedModel = codegen.fromModel("ExtendedModel", extendedModelSchema); + + assertEquals("BaseModel", extendedModel.parent, "ExtendedModel should extend BaseModel"); + + boolean foundInheritedReadOnly = false; + for (CodegenProperty roVar : extendedModel.readOnlyVars) { + if (Boolean.TRUE.equals(roVar.vendorExtensions.get("x-is-inherited-readonly"))) { + foundInheritedReadOnly = true; + } + } + assertTrue(foundInheritedReadOnly, "ExtendedModel should have inherited readOnly properties with x-is-inherited-readonly vendor extension"); + } } diff --git a/modules/openapi-generator/src/test/resources/3_0/issue-inherited-readonly-jsoncreator.yaml b/modules/openapi-generator/src/test/resources/3_0/issue-inherited-readonly-jsoncreator.yaml new file mode 100644 index 000000000000..56723c428d95 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue-inherited-readonly-jsoncreator.yaml @@ -0,0 +1,60 @@ +openapi: 3.0.3 +info: + title: Test API for inherited readonly properties @JsonCreator fix + version: 1.0.0 + description: | + This spec demonstrates the readOnly fix works when there's proper inheritance. + + IMPORTANT NOTE about discriminator: + - The discriminator IS required by OpenAPI Generator to establish the parent-child relationship + - Without a discriminator (or x-parent extension), allOf refs are not recognized as inheritance + - The readOnly fix itself works on any inherited readOnly properties, but inheritance + must first be established via discriminator or x-parent + + The readOnly fix ensures that when a child class inherits readOnly properties from a parent, + the child's @JsonCreator constructor uses protected setters (not direct field access) + for the inherited properties, since they are private in the parent. +paths: {} +components: + schemas: + # Parent with readOnly properties - discriminator REQUIRED for inheritance recognition + # The discriminator is NOT specifically for the readOnly fix, but for inheritance itself + BaseModel: + type: object + discriminator: + propertyName: modelType + properties: + id: + type: string + format: uuid + readOnly: true + description: Read-only identifier inherited from parent + createdAt: + type: string + format: date-time + readOnly: true + description: Read-only creation timestamp inherited from parent + modelType: + type: string + description: Discriminator property for inheritance + name: + type: string + description: Regular read-write property + required: + - id + - modelType + - name + + # Child extends parent - @JsonCreator should use protected setters for inherited readOnly properties + ExtendedModel: + allOf: + - $ref: '#/components/schemas/BaseModel' + - type: object + properties: + description: + type: string + status: + type: string + enum: [active, inactive] + required: + - status diff --git a/modules/openapi-generator/src/test/resources/3_0/issue-oneOf-wrapper-inheritance.yaml b/modules/openapi-generator/src/test/resources/3_0/issue-oneOf-wrapper-inheritance.yaml new file mode 100644 index 000000000000..025f198c1a9e --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/issue-oneOf-wrapper-inheritance.yaml @@ -0,0 +1,58 @@ +openapi: 3.0.3 +info: + title: Test API for oneOf wrapper schema inheritance fix (WITHOUT discriminator) + version: 1.0.0 + description: | + This spec demonstrates the oneOf wrapper fix works without requiring a discriminator. + A oneOf wrapper schema (oneOf + properties, without discriminator) cannot be used as + a parent class. Instead, its properties should be inlined into child schemas. +paths: {} +components: + schemas: + # This schema has oneOf AND properties but NO discriminator - it's still a "oneOf wrapper" + # It should NOT be used as a parent class for inheritance + EventWrapper: + oneOf: + - $ref: '#/components/schemas/ClickEvent' + - $ref: '#/components/schemas/ScrollEvent' + properties: + timestamp: + type: string + format: date-time + readOnly: true + userId: + type: string + sessionId: + type: string + required: + - timestamp + - userId + + # This schema uses allOf to reference the oneOf wrapper + # Properties from EventWrapper should be inlined, not inherited + ClickEvent: + allOf: + - $ref: '#/components/schemas/EventWrapper' + - type: object + properties: + elementId: + type: string + clickX: + type: integer + clickY: + type: integer + required: + - elementId + + ScrollEvent: + allOf: + - $ref: '#/components/schemas/EventWrapper' + - type: object + properties: + scrollDepth: + type: integer + direction: + type: string + enum: [up, down, left, right] + required: + - scrollDepth diff --git a/samples/client/petstore/java/native-async/src/main/java/org/openapitools/client/model/HasOnlyReadOnly.java b/samples/client/petstore/java/native-async/src/main/java/org/openapitools/client/model/HasOnlyReadOnly.java index bec3a4e9f87e..9c0c5c0a95ad 100644 --- a/samples/client/petstore/java/native-async/src/main/java/org/openapitools/client/model/HasOnlyReadOnly.java +++ b/samples/client/petstore/java/native-async/src/main/java/org/openapitools/client/model/HasOnlyReadOnly.java @@ -71,6 +71,12 @@ public String getBar() { } + /** + * Protected setter for bar (readOnly property, used by subclasses' @JsonCreator). + */ + protected void setBar(@javax.annotation.Nullable String bar) { + this.bar = bar; + } /** @@ -85,6 +91,12 @@ public String getFoo() { } + /** + * Protected setter for foo (readOnly property, used by subclasses' @JsonCreator). + */ + protected void setFoo(@javax.annotation.Nullable String foo) { + this.foo = foo; + } /** diff --git a/samples/client/petstore/java/native-async/src/main/java/org/openapitools/client/model/Name.java b/samples/client/petstore/java/native-async/src/main/java/org/openapitools/client/model/Name.java index debcb4a648b6..88ece29b9e35 100644 --- a/samples/client/petstore/java/native-async/src/main/java/org/openapitools/client/model/Name.java +++ b/samples/client/petstore/java/native-async/src/main/java/org/openapitools/client/model/Name.java @@ -105,6 +105,12 @@ public Integer getSnakeCase() { } + /** + * Protected setter for snakeCase (readOnly property, used by subclasses' @JsonCreator). + */ + protected void setSnakeCase(@javax.annotation.Nullable Integer snakeCase) { + this.snakeCase = snakeCase; + } public Name property(@javax.annotation.Nullable String property) { @@ -143,6 +149,12 @@ public Integer get123number() { } + /** + * Protected setter for _123number (readOnly property, used by subclasses' @JsonCreator). + */ + protected void set123number(@javax.annotation.Nullable Integer _123number) { + this._123number = _123number; + } /** diff --git a/samples/client/petstore/java/native-async/src/main/java/org/openapitools/client/model/ReadOnlyFirst.java b/samples/client/petstore/java/native-async/src/main/java/org/openapitools/client/model/ReadOnlyFirst.java index 3d91568f003b..6217160eea7b 100644 --- a/samples/client/petstore/java/native-async/src/main/java/org/openapitools/client/model/ReadOnlyFirst.java +++ b/samples/client/petstore/java/native-async/src/main/java/org/openapitools/client/model/ReadOnlyFirst.java @@ -69,6 +69,12 @@ public String getBar() { } + /** + * Protected setter for bar (readOnly property, used by subclasses' @JsonCreator). + */ + protected void setBar(@javax.annotation.Nullable String bar) { + this.bar = bar; + } public ReadOnlyFirst baz(@javax.annotation.Nullable String baz) { diff --git a/samples/client/petstore/java/native/src/main/java/org/openapitools/client/model/HasOnlyReadOnly.java b/samples/client/petstore/java/native/src/main/java/org/openapitools/client/model/HasOnlyReadOnly.java index 2c86c78e99f3..c1ccfbe55f5c 100644 --- a/samples/client/petstore/java/native/src/main/java/org/openapitools/client/model/HasOnlyReadOnly.java +++ b/samples/client/petstore/java/native/src/main/java/org/openapitools/client/model/HasOnlyReadOnly.java @@ -73,6 +73,12 @@ public String getBar() { } + /** + * Protected setter for bar (readOnly property, used by subclasses' @JsonCreator). + */ + protected void setBar(@javax.annotation.Nullable String bar) { + this.bar = bar; + } /** @@ -87,6 +93,12 @@ public String getFoo() { } + /** + * Protected setter for foo (readOnly property, used by subclasses' @JsonCreator). + */ + protected void setFoo(@javax.annotation.Nullable String foo) { + this.foo = foo; + } /** diff --git a/samples/client/petstore/java/native/src/main/java/org/openapitools/client/model/Name.java b/samples/client/petstore/java/native/src/main/java/org/openapitools/client/model/Name.java index 3fdecf6fb353..32c2dbcbc691 100644 --- a/samples/client/petstore/java/native/src/main/java/org/openapitools/client/model/Name.java +++ b/samples/client/petstore/java/native/src/main/java/org/openapitools/client/model/Name.java @@ -107,6 +107,12 @@ public Integer getSnakeCase() { } + /** + * Protected setter for snakeCase (readOnly property, used by subclasses' @JsonCreator). + */ + protected void setSnakeCase(@javax.annotation.Nullable Integer snakeCase) { + this.snakeCase = snakeCase; + } public Name property(@javax.annotation.Nullable String property) { @@ -145,6 +151,12 @@ public Integer get123number() { } + /** + * Protected setter for _123number (readOnly property, used by subclasses' @JsonCreator). + */ + protected void set123number(@javax.annotation.Nullable Integer _123number) { + this._123number = _123number; + } /** diff --git a/samples/client/petstore/java/native/src/main/java/org/openapitools/client/model/ReadOnlyFirst.java b/samples/client/petstore/java/native/src/main/java/org/openapitools/client/model/ReadOnlyFirst.java index 7c436cb400cd..46614c8e0fda 100644 --- a/samples/client/petstore/java/native/src/main/java/org/openapitools/client/model/ReadOnlyFirst.java +++ b/samples/client/petstore/java/native/src/main/java/org/openapitools/client/model/ReadOnlyFirst.java @@ -71,6 +71,12 @@ public String getBar() { } + /** + * Protected setter for bar (readOnly property, used by subclasses' @JsonCreator). + */ + protected void setBar(@javax.annotation.Nullable String bar) { + this.bar = bar; + } public ReadOnlyFirst baz(@javax.annotation.Nullable String baz) {