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 47bf588ae89f..a6b3612f4a94 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 @@ -2505,10 +2505,74 @@ public static Schema cloneSchema(Schema schema, boolean openapi31) { Schema result = AnnotationsUtils.clone(schema, openapi31); schema.setType(schemaType); result.setType(schemaType); + restoreTypelessSubSchemas(schema, result, + Collections.newSetFromMap(new IdentityHashMap<>())); return result; } } + /** + * Restores the "typeless" nature of sub-schemas after {@link #cloneSchema(Schema, boolean)}. + * + *

{@code AnnotationsUtils.clone} round-trips the schema through JSON. A sub-schema that + * declares no {@code type} (e.g. {@code additionalProperties: {description: can be any type}}) + * is deserialized back as an {@link ObjectSchema} with {@code type: object}, which turns an + * "any type" schema into a free-form object. Downstream that flips + * {@link #isAnyType(Schema)} to false and {@link #isFreeFormObject(Schema, OpenAPI)} to true, + * so the resolved data type silently changes (for typescript: {@code any} becomes + * {@code object}). + * + *

The top-level type is already preserved by the caller; this walks the sub-schemas and + * clears the type wherever the original did not declare one. + * + * @param original the schema that was cloned + * @param clone the clone to fix up, structurally identical to {@code original} + * @param visited identity set of already visited clone nodes + */ + private static void restoreTypelessSubSchemas(Schema original, Schema clone, Set visited) { + // Keyed on the clone, not the original: the parser reuses a single Schema instance across + // several places in a spec, while the JSON round-trip gives each of those places its own + // clone. Keying on the original would fix up only the first occurrence and silently skip + // the rest. + if (original == null || clone == null || !visited.add(clone)) { + return; + } + + if (original.getType() == null && clone.getType() != null) { + clone.setType(null); + if (original.getTypes() == null) { + clone.setTypes(null); + } + } + + if (original.getAdditionalProperties() instanceof Schema + && clone.getAdditionalProperties() instanceof Schema) { + restoreTypelessSubSchemas((Schema) original.getAdditionalProperties(), + (Schema) clone.getAdditionalProperties(), visited); + } + restoreTypelessSubSchemas(original.getItems(), clone.getItems(), visited); + restoreTypelessSubSchemas(original.getNot(), clone.getNot(), visited); + + if (original.getProperties() != null && clone.getProperties() != null) { + Map cloneProperties = clone.getProperties(); + ((Map) original.getProperties()).forEach((name, originalProperty) -> + restoreTypelessSubSchemas(originalProperty, cloneProperties.get(name), visited)); + } + + restoreTypelessSubSchemas(original.getAllOf(), clone.getAllOf(), visited); + restoreTypelessSubSchemas(original.getAnyOf(), clone.getAnyOf(), visited); + restoreTypelessSubSchemas(original.getOneOf(), clone.getOneOf(), visited); + } + + private static void restoreTypelessSubSchemas(List original, List clone, Set visited) { + if (original == null || clone == null || original.size() != clone.size()) { + return; + } + for (int i = 0; i < original.size(); i++) { + restoreTypelessSubSchemas(original.get(i), clone.get(i), visited); + } + } + /** * Simplifies the schema by removing the oneOfAnyOf if the oneOfAnyOf only contains a single non-null sub-schema * diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java index 0ba3cd8a5305..f1c8a6b3bff8 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/typescript/fetch/TypeScriptFetchClientCodegenTest.java @@ -67,6 +67,37 @@ public void testOptionalResponseImports() { Assert.assertEquals(operation.isResponseOptional, true); } + @Test + public void testAllOfInheritedFreeFormMapStaysAny() throws IOException { + // A property whose additionalProperties schema declares no type is "any type". Inheriting + // it through allOf must not change that: the allOf composition path clones the inherited + // property schemas, and the clone used to come back as a free-form object (`object`). + final String specPath = "src/test/resources/3_0/typescript-fetch/allof-inherited-free-form-map.yaml"; + + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("typescript-fetch") + .setInputSpec(specPath) + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + Generator generator = new DefaultGenerator(); + List files = generator.opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + Path base = Paths.get(output + "/models/Base.ts"); + TestUtils.assertFileContains(base, "requiredLoose: { [key: string]: any; };"); + TestUtils.assertFileContains(base, "optionalLoose?: { [key: string]: any; };"); + TestUtils.assertFileContains(base, "realObject?: { [key: string]: object; };"); + + // Child inherits all three through allOf -- the types must be identical to Base's + Path child = Paths.get(output + "/models/Child.ts"); + TestUtils.assertFileContains(child, "requiredLoose: { [key: string]: any; };"); + TestUtils.assertFileContains(child, "optionalLoose?: { [key: string]: any; };"); + TestUtils.assertFileContains(child, "realObject?: { [key: string]: object; };"); + } + @Test public void testModelsWithoutPaths() throws IOException { final String specPath = "src/test/resources/3_1/reusable-components-without-paths.yaml"; 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 2617b34fa437..7805c8a9fd74 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 @@ -461,6 +461,74 @@ public void testCloneArrayOfEnumsSchema() { Assert.assertNotSame(deepCopy, schema); } + @Test + public void testCloneKeepsTypelessAdditionalPropertiesTypeless() { + // additionalProperties: {description: can be any type} -- a schema with no type at all. + // The JSON round-trip inside AnnotationsUtils.clone used to bring it back as an + // ObjectSchema with type=object, turning "any type" into a free-form object. + Schema anyType = new Schema().description("can be any type"); + Schema schema = new ObjectSchema().additionalProperties(anyType); + + Schema deepCopy = ModelUtils.cloneSchema(schema, false); + + Schema copiedAdditionalProperties = (Schema) deepCopy.getAdditionalProperties(); + Assert.assertNotSame(copiedAdditionalProperties, anyType); + Assert.assertNull(copiedAdditionalProperties.getType()); + Assert.assertTrue(ModelUtils.isAnyType(copiedAdditionalProperties)); + Assert.assertFalse(ModelUtils.isFreeFormObject(copiedAdditionalProperties, null)); + } + + @Test + public void testCloneKeepsTypelessNestedSchemasTypeless() { + Schema typelessProperty = new Schema().description("can be any type"); + Schema typelessItem = new Schema().description("can be any type"); + Schema schema = new ObjectSchema() + .addProperty("loose", typelessProperty) + .addProperty("list", new ArraySchema().items(typelessItem)); + + Schema deepCopy = ModelUtils.cloneSchema(schema, false); + + Schema copiedProperty = (Schema) deepCopy.getProperties().get("loose"); + Assert.assertNull(copiedProperty.getType()); + Assert.assertTrue(ModelUtils.isAnyType(copiedProperty)); + + Schema copiedItems = ((Schema) deepCopy.getProperties().get("list")).getItems(); + Assert.assertNull(copiedItems.getType()); + Assert.assertTrue(ModelUtils.isAnyType(copiedItems)); + } + + @Test + public void testCloneKeepsEveryOccurrenceOfAReusedTypelessSchemaTypeless() { + // The parser reuses a single Schema instance across several places in a spec, while the + // JSON round-trip inside the clone gives each place its own object. Every one of them has + // to be fixed up, not just the first one that is reached. + Schema sharedAnyType = new Schema().description("can be any type"); + Schema schema = new ObjectSchema() + .addProperty("first", new ObjectSchema().additionalProperties(sharedAnyType)) + .addProperty("second", new ObjectSchema().additionalProperties(sharedAnyType)); + + Schema deepCopy = ModelUtils.cloneSchema(schema, false); + + for (String name : new String[]{"first", "second"}) { + Schema copied = (Schema) ((Schema) deepCopy.getProperties().get(name)).getAdditionalProperties(); + Assert.assertNull(copied.getType(), name + " kept type: " + copied.getType()); + Assert.assertTrue(ModelUtils.isAnyType(copied), name + " is no longer an any-type schema"); + } + } + + @Test + public void testCloneKeepsDeclaredObjectTypeIntact() { + // a schema that really does declare `type: object` must stay a free-form object + Schema declaredObject = new ObjectSchema().description("a real object"); + Schema schema = new ObjectSchema().additionalProperties(declaredObject); + + Schema deepCopy = ModelUtils.cloneSchema(schema, false); + + Schema copiedAdditionalProperties = (Schema) deepCopy.getAdditionalProperties(); + Assert.assertEquals(copiedAdditionalProperties.getType(), "object"); + Assert.assertFalse(ModelUtils.isAnyType(copiedAdditionalProperties)); + } + @Test public void testCloneDateTimeSchemaWithExample() { Schema schema = new DateTimeSchema() diff --git a/modules/openapi-generator/src/test/resources/3_0/typescript-fetch/allof-inherited-free-form-map.yaml b/modules/openapi-generator/src/test/resources/3_0/typescript-fetch/allof-inherited-free-form-map.yaml new file mode 100644 index 000000000000..d59f14d79f7f --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/typescript-fetch/allof-inherited-free-form-map.yaml @@ -0,0 +1,39 @@ +openapi: 3.0.3 +info: + title: allOf inherited free-form map + version: 1.0.0 +paths: + /child: + get: + operationId: getChild + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "#/components/schemas/Child" +components: + schemas: + Base: + required: + - requiredLoose + properties: + requiredLoose: + type: object + additionalProperties: + description: can be any type + optionalLoose: + type: object + additionalProperties: + description: can be any type + realObject: + type: object + additionalProperties: + type: object + Child: + allOf: + - $ref: "#/components/schemas/Base" + properties: + extra: + type: string