From e1561fe7b80f42d0b9c9cdaff3b1deaf5228ad86 Mon Sep 17 00:00:00 2001 From: ShellWen Date: Mon, 25 May 2026 18:19:22 +0800 Subject: [PATCH] [dart-dio] Fix nullable inline object models --- .../openapitools/codegen/DefaultCodegen.java | 38 ++++++++--------- .../codegen/InlineModelResolver.java | 42 +++++++------------ .../codegen/utils/ModelUtils.java | 10 +++-- .../oas/OpenApiSchemaValidations.java | 15 ++++++- .../codegen/DefaultCodegenTest.java | 19 +++++++++ .../codegen/dart/dio/DartDioModelTest.java | 22 ++++++++++ .../codegen/utils/ModelUtilsTest.java | 9 ++++ .../oas/OpenApiSchemaValidationsTest.java | 24 ++++++++++- .../resources/3_1/dart-dio/issue_23866.yaml | 38 +++++++++++++++++ 9 files changed, 165 insertions(+), 52 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_1/dart-dio/issue_23866.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 d000cde76814..d1db00d24ade 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 @@ -2891,7 +2891,7 @@ protected void updateModelForComposedSchema(CodegenModel m, Schema schema, Map imports) codegenParameter.setTypeProperties(parameterSchema, openAPI); codegenParameter.setComposedSchemas(getComposedSchemas(parameterSchema)); - if (Boolean.TRUE.equals(parameterSchema.getNullable())) { // use nullable defined in the spec - codegenParameter.isNullable = true; - } + codegenParameter.isNullable = ModelUtils.isNullable(parameterSchema); if (parameter.getStyle() != null) { codegenParameter.style = parameter.getStyle().toString(); @@ -8221,10 +8219,8 @@ public CodegenParameter fromRequestBody(RequestBody body, Set imports, S // restore original schema with description, extensions etc if (original != null) { // evaluate common attributes such as description if defined in the top level - if (original.getNullable() != null) { - codegenParameter.isNullable = original.getNullable(); - } else if (original.getExtensions() != null && original.getExtensions().containsKey(X_NULLABLE)) { - codegenParameter.isNullable = (Boolean) original.getExtensions().get(X_NULLABLE); + if (hasNullableMarker(original)) { + codegenParameter.isNullable = ModelUtils.isNullable(original); } if (original.getExtensions() != null) { diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/InlineModelResolver.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/InlineModelResolver.java index b4d64849060c..41ed3f1ab125 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/InlineModelResolver.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/InlineModelResolver.java @@ -826,13 +826,10 @@ private void flattenProperties(OpenAPI openAPI, Map properties, Schema model = modelFromProperty(openAPI, op, modelName); String existing = matchGenerated(model); if (existing != null) { - Schema schema = new Schema().$ref(existing); - schema.setRequired(op.getRequired()); - propsToUpdate.put(key, schema); + propsToUpdate.put(key, makeSchema(existing, op)); } else { modelName = addSchemas(modelName, model); - Schema schema = new Schema().$ref(modelName); - schema.setRequired(op.getRequired()); + Schema schema = makeSchema(modelName, op); propsToUpdate.put(key, schema); modelsToAdd.put(modelName, model); } @@ -846,23 +843,17 @@ private void flattenProperties(OpenAPI openAPI, Map properties, Schema innerModel = modelFromProperty(openAPI, op, modelName); String existing = matchGenerated(innerModel); if (existing != null) { - Schema schema = new Schema().$ref(existing); - schema.setRequired(op.getRequired()); - property.setItems(schema); + property.setItems(makeSchema(existing, op)); } else { modelName = addSchemas(modelName, innerModel); - Schema schema = new Schema().$ref(modelName); - schema.setRequired(op.getRequired()); - property.setItems(schema); + property.setItems(makeSchema(modelName, op)); } } } else if (ModelUtils.isComposedSchema(inner)) { String innerModelName = resolveModelName(inner.getTitle(), path + "_" + key); gatherInlineModels(inner, innerModelName); innerModelName = addSchemas(innerModelName, inner); - Schema schema = new Schema().$ref(innerModelName); - schema.setRequired(inner.getRequired()); - property.setItems(schema); + property.setItems(makeSchema(innerModelName, inner)); } else { LOGGER.debug("Schema not yet handled in model resolver: {}", inner); } @@ -876,23 +867,17 @@ private void flattenProperties(OpenAPI openAPI, Map properties, Schema innerModel = modelFromProperty(openAPI, op, modelName); String existing = matchGenerated(innerModel); if (existing != null) { - Schema schema = new Schema().$ref(existing); - schema.setRequired(op.getRequired()); - property.setAdditionalProperties(schema); + property.setAdditionalProperties(makeSchema(existing, op)); } else { modelName = addSchemas(modelName, innerModel); - Schema schema = new Schema().$ref(modelName); - schema.setRequired(op.getRequired()); - property.setAdditionalProperties(schema); + property.setAdditionalProperties(makeSchema(modelName, op)); } } } else if (ModelUtils.isComposedSchema(inner)) { String innerModelName = resolveModelName(inner.getTitle(), path + "_" + key); gatherInlineModels(inner, innerModelName); innerModelName = addSchemas(innerModelName, inner); - Schema schema = new Schema().$ref(innerModelName); - schema.setRequired(inner.getRequired()); - property.setAdditionalProperties(schema); + property.setAdditionalProperties(makeSchema(innerModelName, inner)); } else { LOGGER.debug("Schema not yet handled in model resolver: {}", inner); } @@ -907,9 +892,7 @@ private void flattenProperties(OpenAPI openAPI, Map properties, String propertyModelName = resolveModelName(property.getTitle(), path + "_" + key); gatherInlineModels(property, propertyModelName); propertyModelName = addSchemas(propertyModelName, property); - Schema schema = new Schema().$ref(propertyModelName); - schema.setRequired(property.getRequired()); - propsToUpdate.put(key, schema); + propsToUpdate.put(key, makeSchema(propertyModelName, property)); } } else { LOGGER.debug("Schema not yet handled in model resolver: {}", property); @@ -1007,6 +990,9 @@ private Schema makeSchemaInComponents(String name, Schema schema) { refSchema = new Schema().$ref(name); } this.copyVendorExtensions(schema, refSchema); + if (ModelUtils.isNullable(schema)) { + refSchema.setNullable(true); + } return refSchema; } @@ -1020,6 +1006,10 @@ private Schema makeSchemaInComponents(String name, Schema schema) { */ private Schema makeSchema(String ref, Schema property) { Schema newProperty = new Schema().$ref(ref); + newProperty.setRequired(property.getRequired()); + if (ModelUtils.isNullable(property)) { + newProperty.setNullable(true); + } this.copyVendorExtensions(property, newProperty); return newProperty; } 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 88346d9046f7..d63d787287ca 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 @@ -1767,9 +1767,10 @@ public static boolean isExtensionParent(Schema schema) { * Return true if the 'nullable' attribute is set to true in the schema, i.e. if the value * of the property can be the null value. *

- * In addition, if the OAS document is 3.1 or above, isNullable returns true if the input - * schema is a 'oneOf' composed document with at most two children, and one of the children - * is the 'null' type. + * In addition, isNullable returns true if the input schema uses JSON Schema nullability + * forms adopted by OAS 3.1: a 'oneOf' composed document with at most two children where + * one child is the 'null' type, or a type-array form where one of the types is 'null'. + * This method checks schema shape only and does not validate the OpenAPI document version. *

* The caller is responsible for resolving schema references before invoking isNullable. * If the input schema is a $ref and the referenced schema has 'nullable: true', this method @@ -1794,6 +1795,9 @@ public static boolean isNullable(Schema schema) { if (schema.getExtensions() != null && schema.getExtensions().get(X_NULLABLE) != null) { return Boolean.parseBoolean(schema.getExtensions().get(X_NULLABLE).toString()); } + if (schema.getTypes() != null && schema.getTypes().contains("null")) { + return true; + } // In OAS 3.1, the recommended way to define a nullable property or object is to use oneOf. if (isComposedSchema(schema)) { return isNullableComposedSchema(schema); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidations.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidations.java index c80f0c52494d..e81ed565f9f6 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidations.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidations.java @@ -1,6 +1,7 @@ package org.openapitools.codegen.validations.oas; import io.swagger.v3.oas.models.media.Schema; +import org.openapitools.codegen.CodegenConstants; import org.openapitools.codegen.utils.ModelUtils; import org.openapitools.codegen.utils.SemVer; import org.openapitools.codegen.validation.GenericValidator; @@ -120,7 +121,7 @@ private static ValidationRule.Result checkNullableAttribute(SchemaWrapper schema if (schemaWrapper.getOpenAPI() != null) { SemVer version = new SemVer(schemaWrapper.getOpenAPI().getOpenapi()); if (version.atLeast("3.1")) { - if (ModelUtils.isNullable(schema)) { + if (usesNullableAttribute(schema)) { result = new ValidationRule.Fail(); result.setDetails(String.format(Locale.ROOT, "OAS document is version '%s'. Schema '%s' uses 'nullable' attribute, which has been deprecated in OAS 3.1.", @@ -132,6 +133,18 @@ private static ValidationRule.Result checkNullableAttribute(SchemaWrapper schema return result; } + private static boolean usesNullableAttribute(Schema schema) { + if (schema == null) { + return false; + } + if (Boolean.TRUE.equals(schema.getNullable())) { + return true; + } + return schema.getExtensions() != null + && schema.getExtensions().get(CodegenConstants.X_NULLABLE) != null + && Boolean.parseBoolean(schema.getExtensions().get(CodegenConstants.X_NULLABLE).toString()); + } + private static String nameOf(Schema schema) { return schema.getName() != null ? schema.getName() : schema.getTitle(); } 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 86225e43ea54..52b16e19b78b 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 @@ -2462,6 +2462,25 @@ public void schemaMappingWithNullableAllOfProperty() { "dataType must resolve to the referenced schema name"); } + @Test + public void nullableTypeArrayProperty() { + DefaultCodegen codegen = new DefaultCodegen(); + OpenAPI openAPI = TestUtils.parseFlattenSpec("src/test/resources/3_1/dart-dio/issue_23866.yaml"); + codegen.setOpenAPI(openAPI); + + CodegenModel model = codegen.fromModel( + "EnvelopeNullableLoginResponse", + openAPI.getComponents().getSchemas().get("EnvelopeNullableLoginResponse")); + + CodegenProperty data = model.vars.stream() + .filter(v -> "data".equals(v.name)) + .findFirst() + .orElseThrow(() -> new AssertionError("data property not found")); + + assertTrue(data.isNullable, + "data must be nullable because its OAS 3.1 type array includes null"); + } + @Test public void operationIdNameMapping() { DefaultCodegen codegen = new DefaultCodegen(); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/dio/DartDioModelTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/dio/DartDioModelTest.java index 0d602450214c..fec2bff90e8f 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/dio/DartDioModelTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/dart/dio/DartDioModelTest.java @@ -520,4 +520,26 @@ public void nestedArrayItemsCanBeNullable() { Assert.assertEquals(codegen.getTypeDeclaration(schema), "BuiltList>"); } + + @Test(description = "OAS 3.1 nullable inline object properties keep nullable field type") + public void nullableInlineObjectPropertyWithTypeArray() { + final OpenAPI openAPI = TestUtils.parseFlattenSpec("src/test/resources/3_1/dart-dio/issue_23866.yaml"); + final DartDioClientCodegen codegen = new DartDioClientCodegen(); + codegen.additionalProperties().put(CodegenConstants.SERIALIZATION_LIBRARY, DartDioClientCodegen.SERIALIZATION_LIBRARY_BUILT_VALUE); + codegen.processOpts(); + codegen.setOpenAPI(openAPI); + + final Schema model = openAPI.getComponents().getSchemas().get("EnvelopeNullableLoginResponse"); + final CodegenModel cm = codegen.fromModel("EnvelopeNullableLoginResponse", model); + + final CodegenProperty data = cm.vars.stream() + .filter(property -> "data".equals(property.baseName)) + .findFirst() + .orElseThrow(() -> new AssertionError("data property not found")); + + Assert.assertEquals(data.dataType, "EnvelopeNullableLoginResponseData"); + Assert.assertEquals(data.datatypeWithEnum, "EnvelopeNullableLoginResponseData"); + Assert.assertTrue(data.required); + Assert.assertTrue(data.isNullable); + } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java index 906ace829bcb..a86e6fd690a6 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java @@ -30,6 +30,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -615,6 +616,14 @@ public void simplifyOneOfAnyOfWithOnlyOneNonNullSubSchemaKeepsParentDescription( assertEquals(anyOfSchemaWithChildDescription.getDescription(), "Child description"); } + @Test + public void isNullableWithTypeArrayNull() { + Schema schema = new Schema<>(); + schema.setTypes(new LinkedHashSet<>(Arrays.asList("object", "null"))); + + assertTrue(ModelUtils.isNullable(schema)); + } + @Test public void isNullTypeSchemaTest() { OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/null_schema_test.yaml"); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidationsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidationsTest.java index ebff8ea7e4b8..54c91c9f6dcb 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidationsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidationsTest.java @@ -1,5 +1,6 @@ package org.openapitools.codegen.validations.oas; +import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.media.*; import org.openapitools.codegen.validation.Invalid; import org.openapitools.codegen.validation.ValidationResult; @@ -8,6 +9,7 @@ import org.testng.annotations.Test; import java.util.Arrays; +import java.util.LinkedHashSet; import java.util.List; import java.util.stream.Collectors; @@ -67,6 +69,26 @@ public void testOneOfWithSiblingPropertiesDisabledRule(Schema schema, boolean ma Assert.assertEquals(warnings.size(), 0, "Expected rule to be disabled."); } + @Test(description = "OAS 3.1 type-array null is not the deprecated nullable attribute") + public void testNullableAttributeRecommendationIgnoresTypeArrayNull() { + RuleConfiguration config = new RuleConfiguration(); + config.setEnableRecommendations(true); + OpenApiSchemaValidations validator = new OpenApiSchemaValidations(config); + + OpenAPI openAPI = new OpenAPI().openapi("3.1.0"); + Schema schema = new Schema(); + schema.setTypes(new LinkedHashSet<>(Arrays.asList("object", "null"))); + + ValidationResult result = validator.validate(new SchemaWrapper(openAPI, schema)); + Assert.assertNotNull(result.getWarnings()); + + List warnings = result.getWarnings().stream() + .filter(invalid -> "Schema uses the 'nullable' attribute.".equals(invalid.getRule().getDescription())) + .collect(Collectors.toList()); + + Assert.assertEquals(warnings.size(), 0, "Expected type-array null not to trigger nullable attribute recommendation."); + } + @DataProvider(name = "apacheNginxRecommendationExpectations") public Object[][] apacheNginxRecommendationExpectations() { return new Object[][]{ @@ -128,4 +150,4 @@ private ComposedSchema getAnyOfSample(boolean withProperties) { return schema; } -} \ No newline at end of file +} diff --git a/modules/openapi-generator/src/test/resources/3_1/dart-dio/issue_23866.yaml b/modules/openapi-generator/src/test/resources/3_1/dart-dio/issue_23866.yaml new file mode 100644 index 000000000000..5db0947c6173 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/dart-dio/issue_23866.yaml @@ -0,0 +1,38 @@ +openapi: 3.1.0 +info: + title: Repro + version: "1.0" +paths: + /login: + post: + operationId: login + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/EnvelopeNullableLoginResponse" +components: + schemas: + EnvelopeNullableLoginResponse: + type: object + required: + - code + - data + - message + properties: + code: + type: integer + data: + type: + - object + - "null" + description: Business data; null on failure + required: + - token + properties: + token: + type: string + message: + type: string