Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)}.
*
* <p>{@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}).
*
* <p>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<Schema> 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<String, Schema> cloneProperties = clone.getProperties();
((Map<String, Schema>) 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<Schema> original, List<Schema> clone, Set<Schema> 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
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<File> 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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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