From fa31da4cad56b48ea7b3d4f69db409f892d3dc11 Mon Sep 17 00:00:00 2001 From: khayashi4337 Date: Wed, 11 Nov 2026 15:02:20 +0900 Subject: [PATCH 1/3] fix(core): mark form parameters required from the schema and its allOf chain fromRequestBodyToFormParameters decided `required` for form parameters in two flawed ways: - the allOf branch compared the collected required names against the normalized paramName, so properties such as `prop-with-dash` or `user_id` lost the flag; - a top-level `required` list short-circuited the allOf lookup, and the collected list came from addProperties, which also unions the required entries of oneOf/anyOf alternatives. Collect required names only from the schema's own list and its allOf chain (resolving $ref, including a single-allOf wrapper) and match them against the schema property name. Within that collection, a oneOf/anyOf contributes only the required entries shared by every branch: a field required by all alternatives is required whichever the caller picks, while branch-only entries must not force the form parameter. New fixture form-allof-required.yaml and seven DefaultCodegenTest cases covering both over-required and under-required regressions. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../openapitools/codegen/DefaultCodegen.java | 87 ++++++- .../codegen/DefaultCodegenTest.java | 156 +++++++++++++ .../resources/3_0/form-allof-required.yaml | 217 ++++++++++++++++++ 3 files changed, 454 insertions(+), 6 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_0/form-allof-required.yaml 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 6b2b7c5d6407..e4ca283587d5 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 @@ -4005,6 +4005,75 @@ protected void updatePropertyForObject(CodegenProperty property, Schema p) { addVarsRequiredVarsAdditionalProps(p, property); } + /** + * Collects {@code required} entries only along the schema's own list and + * its allOf members (a single-allOf wrapper included), resolving $ref. + * Unlike {@link #addProperties}, oneOf/anyOf branches are not traversed + * unconditionally: a branch's required entries only hold if that branch + * is chosen, so only the entries shared by *every* branch of a oneOf/anyOf + * found along the way are real requirements of the enclosing shape. + */ + private void collectRequiredFromAllOfChain(Schema schema, List required, Set visitedSchemas) { + required.addAll(guaranteedRequired(schema, visitedSchemas)); + } + + /** + * Returns the {@code required} entries guaranteed by the schema itself: + * its own list, its allOf members' guarantees and, for oneOf/anyOf, the + * entries every branch requires (a field required by all alternatives is + * required no matter which one the caller picks). {@code visitedSchemas} + * keeps cyclic $ref chains from recursing forever; a schema already + * consumed contributes nothing the second time. + */ + private Set guaranteedRequired(Schema schema, Set visitedSchemas) { + Set required = new LinkedHashSet<>(); + if (schema == null || !visitedSchemas.add(schema)) { + return required; + } + if (StringUtils.isNotBlank(schema.get$ref())) { + required.addAll(guaranteedRequired(ModelUtils.getReferencedSchema(this.openAPI, schema), visitedSchemas)); + return required; + } + if (schema.getRequired() != null) { + required.addAll(schema.getRequired()); + } + if (schema.getAllOf() != null) { + for (Object component : schema.getAllOf()) { + required.addAll(guaranteedRequired((Schema) component, visitedSchemas)); + } + } + required.addAll(commonBranchRequired(schema.getOneOf(), visitedSchemas)); + required.addAll(commonBranchRequired(schema.getAnyOf(), visitedSchemas)); + return required; + } + + /** + * Returns the {@code required} entries shared by every branch of a + * oneOf/anyOf list: each branch (a $ref resolved, its own allOf chain + * included) contributes its guaranteed entries and only the intersection + * holds for the enclosing schema. + */ + private Set commonBranchRequired(List branches, Set visitedSchemas) { + Set common = null; + if (branches != null) { + for (Schema branch : branches) { + // each branch gets its own copy of the visited set: a schema + // consumed while walking one branch must still contribute its + // required entries when reached again through a sibling branch + Set branchRequired = guaranteedRequired(branch, new HashSet<>(visitedSchemas)); + if (common == null) { + common = new LinkedHashSet<>(branchRequired); + } else { + common.retainAll(branchRequired); + } + if (common.isEmpty()) { + break; + } + } + } + return common == null ? Collections.emptySet() : common; + } + protected void updatePropertyForAnyType(CodegenProperty property, Schema p) { property.isNullable = property.isNullable || !(ModelUtils.isComposedSchema(p)) || @@ -7846,7 +7915,14 @@ public List fromRequestBodyToFormParameters(RequestBody body, // TODO in the future have this return one codegenParameter of type object or composed which includes all definition // that will be needed for complex composition use cases // https://github.com/OpenAPITools/openapi-generator/issues/10415 - addProperties(properties, allRequired, schema, new HashSet<>()); + addProperties(properties, new ArrayList<>(), schema, new HashSet<>()); + // addProperties unions the required list of every composed member, + // including oneOf/anyOf alternatives whose required entries only hold + // when that branch is chosen. For form parameters only the schema's own + // required list and the ones along the allOf chain are real + // requirements, so collect them separately. Starting from the original + // (pre-unwrap) schema also picks up a single-allOf wrapper's required. + collectRequiredFromAllOfChain(original != null ? original : schema, allRequired, new HashSet<>()); boolean isOneOfOrAnyOf = ModelUtils.isOneOf(schema) || ModelUtils.isAnyOf(schema); @@ -7863,12 +7939,11 @@ public List fromRequestBodyToFormParameters(RequestBody body, // for oneOf/anyOf, mark all the properties collected from the sub-schemas as optional // so that users can choose which property to include in the form parameters codegenParameter.required = false; - } else if (!codegenParameter.required && schema.getRequired() != null) { - // Set 'required' flag defined in the schema element - codegenParameter.required = schema.getRequired().contains(entry.getKey()); } else if (!codegenParameter.required) { - // Set 'required' flag for properties declared inside the allOf - codegenParameter.required = allRequired.stream().anyMatch(r -> r.equals(codegenParameter.paramName)); + // 'required' applies to the schema property name (baseName); comparing + // against the normalized paramName would silently drop the flag for + // e.g. snake_case names or collision-renamed parameters. + codegenParameter.required = allRequired.contains(entry.getKey()); } parameters.add(codegenParameter); 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 40229289a7b0..3942c96e52a7 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 @@ -524,6 +524,162 @@ public void testOAS31ContentMediaTypeBinaryFormParameter() { assertFalse(image.isFile); } + @Test + public void testAllOfFormRequiredMatchesByBaseName() { + // required matching must use the schema property name (baseName), not the + // normalized paramName: `user_id` -> `userId` used to silently drop the flag + final OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/form-allof-required.yaml"); + + final DefaultCodegen codegen = new DefaultCodegen(); + codegen.setOpenAPI(openAPI); + + RequestBody requestBody = openAPI.getPaths().get("/register").getPost().getRequestBody(); + List formParams = codegen.fromRequestBodyToFormParameters(requestBody, new HashSet<>()); + Map paramsByBaseName = formParams.stream() + .collect(Collectors.toMap(param -> param.baseName, param -> param)); + + assertTrue(paramsByBaseName.get("query").required, + "allOf member required must apply"); + assertTrue(paramsByBaseName.get("user_id").required, + "required must match the schema name even when paramName is normalized to userId"); + assertTrue(paramsByBaseName.get("prop-with-dash").required, + "required must match the schema name even when paramName is normalized to propWithDash"); + assertFalse(paramsByBaseName.get("nickname").required, + "non-required allOf member stays optional"); + } + + @Test + public void testTopLevelRequiredDoesNotMaskAllOfMemberRequired() { + // sibling bug: a top-level `required` used to bypass the allOf-member + // required lists entirely + final OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/form-allof-required.yaml"); + + final DefaultCodegen codegen = new DefaultCodegen(); + codegen.setOpenAPI(openAPI); + + RequestBody requestBody = openAPI.getPaths().get("/register-top").getPost().getRequestBody(); + List formParams = codegen.fromRequestBodyToFormParameters(requestBody, new HashSet<>()); + Map paramsByBaseName = formParams.stream() + .collect(Collectors.toMap(param -> param.baseName, param -> param)); + + assertTrue(paramsByBaseName.get("top").required, + "top-level required must apply"); + assertTrue(paramsByBaseName.get("member_req").required, + "allOf member required must apply alongside top-level required"); + assertFalse(paramsByBaseName.get("member_opt").required, + "non-required member stays optional"); + } + + @Test + public void testOneOfBranchesDoNotForceFormRequired() { + // a schema carrying both `properties`/`required` and oneOf branches is not + // caught by ModelUtils.isOneOf (which requires empty properties); without + // care the allOf-style required union would also force branch-only fields + final OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/form-allof-required.yaml"); + + final DefaultCodegen codegen = new DefaultCodegen(); + codegen.setOpenAPI(openAPI); + + RequestBody requestBody = openAPI.getPaths().get("/register-mixed").getPost().getRequestBody(); + List formParams = codegen.fromRequestBodyToFormParameters(requestBody, new HashSet<>()); + Map paramsByBaseName = formParams.stream() + .collect(Collectors.toMap(param -> param.baseName, param -> param)); + + assertTrue(paramsByBaseName.get("common").required, + "top-level required must apply"); + assertFalse(paramsByBaseName.get("a").required, + "oneOf branch required must not force the form parameter"); + assertFalse(paramsByBaseName.get("b").required, + "oneOf branch required must not force the form parameter"); + } + + @Test + public void testSingleAllOfWrapperOwnRequired() { + // the single-allOf unwrapping keeps the wrapper schema in `original`; its + // own `required` list must still apply to member properties + final OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/form-allof-required.yaml"); + + final DefaultCodegen codegen = new DefaultCodegen(); + codegen.setOpenAPI(openAPI); + + RequestBody requestBody = openAPI.getPaths().get("/register-wrapper").getPost().getRequestBody(); + List formParams = codegen.fromRequestBodyToFormParameters(requestBody, new HashSet<>()); + Map paramsByBaseName = formParams.stream() + .collect(Collectors.toMap(param -> param.baseName, param -> param)); + + assertTrue(paramsByBaseName.get("wrapper_req").required, + "allOf wrapper's own required must apply"); + assertFalse(paramsByBaseName.get("wrapper_opt").required, + "non-required member stays optional"); + } + + @Test + public void testOneOfMemberInsideAllOfDoesNotForceBranchRequired() { + // a oneOf inside the allOf chain must not union branch requireds into + // the form: `a`/`b` are only mandatory when that branch is chosen. + // master's addProperties union forced both, so this fails on master + final OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/form-allof-required.yaml"); + + final DefaultCodegen codegen = new DefaultCodegen(); + codegen.setOpenAPI(openAPI); + + RequestBody requestBody = openAPI.getPaths().get("/register-intersect").getPost().getRequestBody(); + List formParams = codegen.fromRequestBodyToFormParameters(requestBody, new HashSet<>()); + Map paramsByBaseName = formParams.stream() + .collect(Collectors.toMap(param -> param.baseName, param -> param)); + + assertTrue(paramsByBaseName.get("base_req").required, + "allOf member required must apply"); + assertFalse(paramsByBaseName.get("a").required, + "oneOf branch-only required must not force the form parameter"); + assertFalse(paramsByBaseName.get("b").required, + "oneOf branch-only required must not force the form parameter"); + } + + @Test + public void testRequiredSharedByAllOneOfBranchesInsideAllOfApplies() { + // `x` is required by every branch of the oneOf inside the allOf chain, + // so it is required no matter which branch the caller picks — dropping + // it would be an under-required regression versus master's union + final OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/form-allof-required.yaml"); + + final DefaultCodegen codegen = new DefaultCodegen(); + codegen.setOpenAPI(openAPI); + + RequestBody requestBody = openAPI.getPaths().get("/register-allbranches").getPost().getRequestBody(); + List formParams = codegen.fromRequestBodyToFormParameters(requestBody, new HashSet<>()); + Map paramsByBaseName = formParams.stream() + .collect(Collectors.toMap(param -> param.baseName, param -> param)); + + assertTrue(paramsByBaseName.get("x").required, + "required shared by every oneOf branch must apply (incl. via $ref)"); + assertFalse(paramsByBaseName.get("only_a").required, + "branch-only required stays optional"); + assertFalse(paramsByBaseName.get("only_b").required, + "branch-only required stays optional"); + } + + @Test + public void testRequiredSharedByAllAnyOfBranchesInsideAllOfApplies() { + // same intersection rule for anyOf members of the allOf chain + final OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/form-allof-required.yaml"); + + final DefaultCodegen codegen = new DefaultCodegen(); + codegen.setOpenAPI(openAPI); + + RequestBody requestBody = openAPI.getPaths().get("/register-anyof-common").getPost().getRequestBody(); + List formParams = codegen.fromRequestBodyToFormParameters(requestBody, new HashSet<>()); + Map paramsByBaseName = formParams.stream() + .collect(Collectors.toMap(param -> param.baseName, param -> param)); + + assertTrue(paramsByBaseName.get("x2").required, + "required shared by every anyOf branch must apply"); + assertFalse(paramsByBaseName.get("aa").required, + "branch-only required stays optional"); + assertFalse(paramsByBaseName.get("bb").required, + "branch-only required stays optional"); + } + @Test public void testOriginalOpenApiDocumentVersion() { // Test with OAS 2.0 document. diff --git a/modules/openapi-generator/src/test/resources/3_0/form-allof-required.yaml b/modules/openapi-generator/src/test/resources/3_0/form-allof-required.yaml new file mode 100644 index 000000000000..549b87b9d9d1 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/form-allof-required.yaml @@ -0,0 +1,217 @@ +openapi: 3.0.0 +info: + title: form allOf required + version: 1.0.0 +paths: + /register: + post: + operationId: register + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + allOf: + - type: object + required: + - query + properties: + query: + type: string + - type: object + required: + - user_id + - prop-with-dash + properties: + user_id: + type: string + prop-with-dash: + type: string + nickname: + type: string + responses: + '200': + description: ok + /register-top: + post: + operationId: registerTop + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: + - top + properties: + top: + type: string + allOf: + - type: object + required: + - member_req + properties: + member_req: + type: string + member_opt: + type: string + responses: + '200': + description: ok + /register-mixed: + post: + operationId: registerMixed + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: + - common + properties: + common: + type: string + oneOf: + - type: object + required: + - a + properties: + a: + type: string + - type: object + required: + - b + properties: + b: + type: string + responses: + '200': + description: ok + /register-wrapper: + post: + operationId: registerWrapper + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + required: + - wrapper_req + allOf: + - type: object + properties: + wrapper_req: + type: string + wrapper_opt: + type: string + responses: + '200': + description: ok + /register-intersect: + post: + operationId: registerIntersect + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + allOf: + - type: object + required: + - base_req + properties: + base_req: + type: string + - oneOf: + - type: object + required: + - a + properties: + a: + type: string + - type: object + required: + - b + properties: + b: + type: string + responses: + '200': + description: ok + /register-allbranches: + post: + operationId: registerAllbranches + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + allOf: + - type: object + required: + - base_req + properties: + base_req: + type: string + - oneOf: + - type: object + required: + - x + - only_a + properties: + x: + type: string + only_a: + type: string + - $ref: '#/components/schemas/BranchB' + responses: + '200': + description: ok + /register-anyof-common: + post: + operationId: registerAnyofCommon + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + allOf: + - type: object + properties: + base_opt: + type: string + - anyOf: + - type: object + required: + - x2 + - aa + properties: + x2: + type: string + aa: + type: string + - type: object + required: + - x2 + - bb + properties: + x2: + type: string + bb: + type: string + responses: + '200': + description: ok +components: + schemas: + BranchB: + type: object + required: + - x + - only_b + properties: + x: + type: string + only_b: + type: string From e82af8a867b8e2a4c2d0868bbf29f49863f47504 Mon Sep 17 00:00:00 2001 From: khayashi4337 Date: Thu, 12 Nov 2026 11:34:40 +0900 Subject: [PATCH 2/3] fix(core): honor required listed next to $ref in form schemas guaranteedRequired returned early on a $ref, so a `required` list written alongside the reference (allowed in OAS 3.1 / JSON Schema 2020-12) was silently dropped and the corresponding form parameters stayed optional. The schema's own `required` is now collected before following the $ref, both when the pair sits on an allOf member and on a named schema reached through one. New 3.1 fixture form-ref-sibling-required.yaml with a DefaultCodegenTest case covering both placements. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../openapitools/codegen/DefaultCodegen.java | 9 ++-- .../codegen/DefaultCodegenTest.java | 25 +++++++++ .../3_1/form-ref-sibling-required.yaml | 52 +++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_1/form-ref-sibling-required.yaml 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 e4ca283587d5..d359ee74f225 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 @@ -4030,13 +4030,16 @@ private Set guaranteedRequired(Schema schema, Set visitedSchemas if (schema == null || !visitedSchemas.add(schema)) { return required; } + // collect the schema's own `required` before following $ref: in OAS + // 3.1 a `required` list may sit next to $ref and would otherwise be + // dropped when only the referenced schema is visited + if (schema.getRequired() != null) { + required.addAll(schema.getRequired()); + } if (StringUtils.isNotBlank(schema.get$ref())) { required.addAll(guaranteedRequired(ModelUtils.getReferencedSchema(this.openAPI, schema), visitedSchemas)); return required; } - if (schema.getRequired() != null) { - required.addAll(schema.getRequired()); - } if (schema.getAllOf() != null) { for (Object component : schema.getAllOf()) { required.addAll(guaranteedRequired((Schema) component, visitedSchemas)); 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 3942c96e52a7..2be8d2c5849b 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 @@ -680,6 +680,31 @@ public void testRequiredSharedByAllAnyOfBranchesInsideAllOfApplies() { "branch-only required stays optional"); } + @Test + public void testRequiredSiblingOfRefApplies() { + // OAS 3.1 allows a `required` list next to $ref (JSON Schema 2020-12). + // guaranteedRequired used to return the referenced schema's entries + // only, silently dropping the sibling list + final OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/form-ref-sibling-required.yaml"); + + final DefaultCodegen codegen = new DefaultCodegen(); + codegen.setOpenAPI(openAPI); + + for (String path : new String[]{"/register-ref-sibling", "/register-inline-sibling"}) { + RequestBody requestBody = openAPI.getPaths().get(path).getPost().getRequestBody(); + List formParams = codegen.fromRequestBodyToFormParameters(requestBody, new HashSet<>()); + Map paramsByBaseName = formParams.stream() + .collect(Collectors.toMap(param -> param.baseName, param -> param)); + + assertTrue(paramsByBaseName.get("base_req").required, + path + ": required of the referenced schema must apply"); + assertTrue(paramsByBaseName.get("extra").required, + path + ": required listed next to $ref must apply"); + assertFalse(paramsByBaseName.get("optional_field").required, + path + ": non-required property stays optional"); + } + } + @Test public void testOriginalOpenApiDocumentVersion() { // Test with OAS 2.0 document. diff --git a/modules/openapi-generator/src/test/resources/3_1/form-ref-sibling-required.yaml b/modules/openapi-generator/src/test/resources/3_1/form-ref-sibling-required.yaml new file mode 100644 index 000000000000..0be26eec49b2 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/form-ref-sibling-required.yaml @@ -0,0 +1,52 @@ +openapi: 3.1.0 +info: + title: form required sibling of $ref + version: 1.0.0 +paths: + /register-ref-sibling: + post: + operationId: registerRefSibling + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + allOf: + - $ref: '#/components/schemas/RefCarryingRequired' + responses: + '200': + description: ok + /register-inline-sibling: + post: + operationId: registerInlineSibling + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + allOf: + - $ref: '#/components/schemas/Base' + required: + - extra + responses: + '200': + description: ok +components: + schemas: + # OAS 3.1 (JSON Schema 2020-12) allows keywords next to $ref; the + # `required` here must apply on top of the referenced schema's own list + RefCarryingRequired: + $ref: '#/components/schemas/Base' + required: + - extra + Base: + type: object + required: + - base_req + properties: + base_req: + type: string + extra: + type: string + optional_field: + type: string From b6cd9f2b43970437b0b9c68085813acf428da99e Mon Sep 17 00:00:00 2001 From: khayashi4337 Date: Fri, 13 Nov 2026 13:57:43 +0900 Subject: [PATCH 3/3] fix(core): honor composition keywords next to $ref in form schemas guaranteedRequired returned right after following a $ref, so an allOf/oneOf/anyOf written alongside the reference (allowed in OAS 3.1 / JSON Schema 2020-12) was silently dropped and its required entries never reached the form parameters. The traversal now continues into the sibling composition keywords after collecting the referenced schema's guarantees; visitedSchemas still breaks cyclic $ref chains. The 3.1 fixture form-ref-sibling-required.yaml gains two cases: a $ref paired with an allOf whose member lists `required`, and a $ref paired with a oneOf where every branch requires the same field. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../openapitools/codegen/DefaultCodegen.java | 3 +- .../codegen/DefaultCodegenTest.java | 21 +++++++ .../3_1/form-ref-sibling-required.yaml | 55 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) 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 d359ee74f225..7cc318ee70b9 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 @@ -4036,9 +4036,10 @@ private Set guaranteedRequired(Schema schema, Set visitedSchemas if (schema.getRequired() != null) { required.addAll(schema.getRequired()); } + // keep going after $ref: in OAS 3.1 allOf/oneOf/anyOf may also sit + // next to it and their guaranteed entries apply on top if (StringUtils.isNotBlank(schema.get$ref())) { required.addAll(guaranteedRequired(ModelUtils.getReferencedSchema(this.openAPI, schema), visitedSchemas)); - return required; } if (schema.getAllOf() != null) { for (Object component : schema.getAllOf()) { 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 2be8d2c5849b..4d3a66c1106f 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 @@ -703,6 +703,27 @@ public void testRequiredSiblingOfRefApplies() { assertFalse(paramsByBaseName.get("optional_field").required, path + ": non-required property stays optional"); } + + // OAS 3.1 also allows allOf/oneOf/anyOf next to $ref; guaranteedRequired + // used to return after following the reference, dropping the sibling + // composition keywords + RequestBody requestBody = openAPI.getPaths().get("/register-ref-allof-sibling").getPost().getRequestBody(); + List formParams = codegen.fromRequestBodyToFormParameters(requestBody, new HashSet<>()); + Map paramsByBaseName = formParams.stream() + .collect(Collectors.toMap(param -> param.baseName, param -> param)); + + assertTrue(paramsByBaseName.get("allof_req").required, + "required of an allOf member listed next to $ref must apply"); + + requestBody = openAPI.getPaths().get("/register-ref-oneof-sibling").getPost().getRequestBody(); + formParams = codegen.fromRequestBodyToFormParameters(requestBody, new HashSet<>()); + paramsByBaseName = formParams.stream() + .collect(Collectors.toMap(param -> param.baseName, param -> param)); + + assertTrue(paramsByBaseName.get("oneof_req").required, + "required shared by every oneOf branch listed next to $ref must apply"); + assertFalse(paramsByBaseName.get("oneof_opt").required, + "non-required property of a oneOf branch stays optional"); } @Test diff --git a/modules/openapi-generator/src/test/resources/3_1/form-ref-sibling-required.yaml b/modules/openapi-generator/src/test/resources/3_1/form-ref-sibling-required.yaml index 0be26eec49b2..8e8dacdb2411 100644 --- a/modules/openapi-generator/src/test/resources/3_1/form-ref-sibling-required.yaml +++ b/modules/openapi-generator/src/test/resources/3_1/form-ref-sibling-required.yaml @@ -31,6 +31,32 @@ paths: responses: '200': description: ok + /register-ref-allof-sibling: + post: + operationId: registerRefAllofSibling + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + allOf: + - $ref: '#/components/schemas/RefWithSiblingAllOf' + responses: + '200': + description: ok + /register-ref-oneof-sibling: + post: + operationId: registerRefOneofSibling + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + allOf: + - $ref: '#/components/schemas/RefWithSiblingOneOf' + responses: + '200': + description: ok components: schemas: # OAS 3.1 (JSON Schema 2020-12) allows keywords next to $ref; the @@ -50,3 +76,32 @@ components: type: string optional_field: type: string + # composition keywords may sit next to $ref too: the allOf member's + # `required` must apply on top of the referenced schema's own list + RefWithSiblingAllOf: + $ref: '#/components/schemas/Base' + allOf: + - type: object + required: + - allof_req + properties: + allof_req: + type: string + # for oneOf only the entries every branch requires are guaranteed + RefWithSiblingOneOf: + $ref: '#/components/schemas/Base' + oneOf: + - type: object + required: + - oneof_req + properties: + oneof_req: + type: string + - type: object + required: + - oneof_req + properties: + oneof_req: + type: string + oneof_opt: + type: string