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