Skip to content

Commit 170778a

Browse files
fix(InlineModelResolver): prevent numbered duplicate models from multi-file OAS 3.1 specs (#23856)
* fix(InlineModelResolver): prevent numbered duplicate models from multi-file OAS 3.1 specs When a multi-file OpenAPI 3.1 spec uses external $ref schemas (e.g. JSON Schema files), the swagger-parser resolves them into shared, mutable Java Schema objects. Between processing passes the parser mutates these shared objects in several ways that cause matchGenerated() to produce false misses, which in turn cause numbered duplicate component schemas to be emitted: * Strips 'type' annotations (e.g. type:"string") from property sub-schemas * Strips 'description' text from shared property sub-schemas * Leaves 'example' set on one $ref usage but absent on another (OAS 3.1 allows 'example' as a $ref sibling) * Stores an explicit 'default: null' in YAML as a Jackson NullNode (a non-null Java object serialised as '"default":null'), while a property with no 'default' keyword at all produces a Java null that the NON_NULL mapper omits — two representations of "no default" that compared unequal These mutations caused matchGenerated() to fail its structural comparison, creating e.g. Flow_Segment_1 alongside Flow_Segment for the TAMS-BBC spec. Changes: 1. IgnoreVolatileFieldsMixIn — extends @JsonIgnoreProperties to strip 'description', 'type', and 'example' at every level of the schema graph when computing the structural signature. 2. computeStructuralSignature() — replaces direct structuralMapper calls. After mixin-based serialisation it parses the JSON tree and removes any "default":null (NullNode) entries before returning the key, making an explicit null default compare equal to an absent default while preserving real non-null defaults (e.g. default:"available"). 3. Pre-populate generatedSignature / generatedStructuralSignature with titled schemas already in components/schemas before flattening begins, so that subsequent inline schemas matching existing ones reuse the existing name rather than receiving a numbered suffix. Only titled schemas are pre-populated: a schema identified only by its YAML key in components/schemas has no inherent identity — two anonymous schemas that share the same properties may be intentionally distinct. 4. deduplicateComponents() — post-flatten safety-net pass that scans components/schemas for titled schemas whose structural signatures match, removes the numbered duplicate (e.g. Flow_Segment_1), and rewrites every $ref throughout paths, webhooks, and component schemas to point to the canonical name. 5. Tests — two new regression tests in InlineModelResolverTest plus a set of multi-file YAML fixtures in src/test/resources/3_0/inline-model- resolver-dedup/ that reproduce the parser-mutation scenario: * resolveInlineModelDeduplicatesWhenParserMutatesPropertyTypes — verifies the structural-hash fallback fires when the parser strips 'type' from string properties of a shared Schema object * deduplicateComponentsRemovesNumberedDuplicateOfTitledSchemaAndRewritesRefs — verifies the post-flatten dedup removes the numbered copy and rewrites all $refs in path responses * chore(samples): regenerate affected samples after InlineModelResolver deduplication fixes Regenerated all samples via bin/generate-samples.sh (maven:3.9-eclipse-temurin-11, matching CI JDK version) to reflect the corrected InlineModelResolver behaviour introduced in the two preceding commits. Affected generators and the root cause for each change: csharp-generichost (FormModels, net4.7/4.8/8/9/10) TestEnumParametersEnumHeaderStringParameter removed; enum_header_string and enum_query_string parameters now typed as TestEnumParametersRequestEnumFormString. Reason: the header-string and query-string inline enums are structurally identical to the form-string enum; the structural deduplication map (ignoring description fields) now correctly merges them instead of generating a numbered duplicate. python / python-aiohttp / python-httpx / python-lazyImports / python-pydantic-v1 / python-pydantic-v1-aiohttp UploadFileWithAdditionalPropertiesRequestObject removed; the upload_file_with_ additional_properties endpoint's object parameter is now typed as TestObjectForMultipartRequestsRequestMarker. Reason: same structural-deduplication fix — the inline request schema is structurally identical to the pre-existing TestObjectForMultipartRequestsRequestMarker component and is now correctly reused rather than generating a new model class. php-laravel FakeApiInterface / FakeController updated to reflect the merged type names for both enum and upload-object parameters. rust-server / rust-server-deprecated (petstore-with-fake-endpoints-models-for-testing) TestEnumParametersEnumHeaderStringParameter model and all usages replaced with TestEnumParametersRequestEnumFormString across models.rs, cli.rs, client/mod.rs, server/mod.rs, and example files. openapi.yaml inlined schemas updated accordingly. No functional change — the generated clients/servers remain equivalent; only the model class names for previously-duplicate anonymous schemas have been normalised. * fix(samples): resolve CI failures caused by incomplete sample regeneration after InlineModelResolver deduplication The previous sample regeneration (1214fd1) after the InlineModelResolver deduplication fix left two sets of samples in an inconsistent state: C# GenericHost FormModels (net8, net9, net10, net4.7, net4.8): - TestEnumParametersEnumHeaderStringParameter was correctly identified as a structural duplicate of TestEnumParametersRequestEnumFormString and deduplicated, but the model files (.cs, .md, test .cs) were not deleted from the committed samples. The API implementation was updated to use TestEnumParametersRequestEnumFormString, but FakeApiTests.cs still referenced the deleted type, causing CS1503 compile errors. - Fix: delete the three orphaned TestEnumParametersEnumHeaderStringParameter files per variant and update FakeApiTests.cs to use TestEnumParametersRequestEnumFormString for enumHeaderString and enumQueryString parameters. Python petstore samples (python, python-aiohttp, python-httpx, python-lazyImports, python-pydantic-v1, python-pydantic-v1-aiohttp): - UploadFileWithAdditionalPropertiesRequestObject was correctly deduplicated to TestObjectForMultipartRequestsRequestMarker (structurally identical: both have name: Optional[str] with additional properties). The generated API method upload_file_with_additional_properties now uses TestObjectForMultipartRequestsRequestMarker as the object parameter type, but the hand-written test_rest.py in each variant still instantiated the old UploadFileWithAdditionalPropertiesRequestObject, causing AttributeError at runtime. - Fix: update test_rest.py in all six variants to use TestObjectForMultipartRequestsRequestMarker instead. All fixes verified locally via Docker (Python 3.12, poetry run pytest).
1 parent 17a4d96 commit 170778a

125 files changed

Lines changed: 824 additions & 1828 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

modules/openapi-generator/src/main/java/org/openapitools/codegen/InlineModelResolver.java

Lines changed: 287 additions & 2 deletions
Large diffs are not rendered by default.

modules/openapi-generator/src/test/java/org/openapitools/codegen/InlineModelResolverTest.java

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import io.swagger.v3.oas.models.OpenAPI;
2323
import io.swagger.v3.oas.models.Operation;
2424
import io.swagger.v3.oas.models.PathItem;
25+
import io.swagger.v3.oas.models.Paths;
2526
import io.swagger.v3.oas.models.media.*;
2627
import io.swagger.v3.oas.models.parameters.Parameter;
2728
import io.swagger.v3.oas.models.parameters.RequestBody;
@@ -175,6 +176,125 @@ public void resolveInlineModel2EqualInnerModels() {
175176
assertNull(duplicateAddress);
176177
}
177178

179+
@Test
180+
public void resolveInlineModelDeduplicatesAgainstExistingComponentSchema() {
181+
// When a pre-existing components/schemas entry has the same title and content as an
182+
// inline schema encountered during flattening, the inline schema should reuse the
183+
// pre-existing name rather than being registered as a numbered variant (e.g. Foo_1).
184+
// Regression test for: external $ref chains producing ContainerMapping + ContainerMapping_1.
185+
OpenAPI openapi = new OpenAPI();
186+
openapi.setComponents(new Components());
187+
188+
// Pre-existing named schema registered directly in components
189+
openapi.getComponents().addSchemas("ContainerMapping", new ObjectSchema()
190+
.title("ContainerMapping")
191+
.description("Describes the mapping of essence from a container")
192+
.addProperty("track_index", new IntegerSchema()));
193+
194+
// Schema whose inline property content is identical to the pre-existing ContainerMapping
195+
openapi.getComponents().addSchemas("FlowCore", new ObjectSchema()
196+
.name("FlowCore")
197+
.addProperty("container_mapping", new ObjectSchema()
198+
.title("ContainerMapping")
199+
.description("Describes the mapping of essence from a container")
200+
.addProperty("track_index", new IntegerSchema())));
201+
202+
new InlineModelResolver().flatten(openapi);
203+
204+
// The pre-existing entry must still exist
205+
assertNotNull(openapi.getComponents().getSchemas().get("ContainerMapping"));
206+
// No numbered duplicate should have been created
207+
assertNull(openapi.getComponents().getSchemas().get("ContainerMapping_1"));
208+
}
209+
210+
@Test
211+
public void resolveInlineModelDeduplicatesAcrossExternalRefChains() {
212+
// Regression test: same external schema referenced from two schemas (one via plain $ref,
213+
// one via $ref + sibling description as allowed in OpenAPI 3.1) must not produce a
214+
// duplicate numbered model (Container_Mapping_1).
215+
ParseOptions parseOptions = new ParseOptions();
216+
parseOptions.setResolve(true);
217+
parseOptions.setResolveResponses(true);
218+
OpenAPI openAPI = new OpenAPIParser().readLocation(
219+
"src/test/resources/3_0/inline-model-resolver-dedup/root.yaml",
220+
null, parseOptions).getOpenAPI();
221+
new InlineModelResolver().flatten(openAPI);
222+
223+
Map<String, Schema> schemas = openAPI.getComponents().getSchemas();
224+
assertNotNull(schemas.get("Container_Mapping"));
225+
assertNull("Duplicate Container_Mapping_1 must not exist", schemas.get("Container_Mapping_1"));
226+
}
227+
228+
@Test
229+
public void resolveInlineModelDeduplicatesWhenParserMutatesPropertyDescriptions() {
230+
// Regression test: when the Swagger Parser shares and mutates resolved Schema objects
231+
// (e.g. a shared uuid.json resolved Schema has its description overwritten by whichever
232+
// property referencing it was last processed), two Schema objects from the same external
233+
// file may end up with different serialised JSON despite being structurally identical.
234+
// The structural-hash fallback in matchGenerated() (serialised without any 'description'
235+
// fields at any level) must still deduplicate them rather than creating a numbered variant.
236+
OpenAPI openapi = new OpenAPI();
237+
openapi.setComponents(new Components());
238+
openapi.setPaths(new Paths());
239+
240+
// Schema A: properties have "correct" descriptions (as the file author wrote them)
241+
Schema widgetA = new ObjectSchema()
242+
.title("Widget")
243+
.description("A reusable widget")
244+
.addProperty("id", new StringSchema().description("Widget identifier"))
245+
.addProperty("name", new StringSchema().description("Widget name"));
246+
247+
// Schema B: same title/description/property-names, but 'id' has a DIFFERENT description
248+
// (simulating what the Swagger Parser does when it shares and mutates a resolved sub-schema)
249+
Schema widgetB = new ObjectSchema()
250+
.title("Widget")
251+
.description("A reusable widget")
252+
.addProperty("id", new StringSchema().description("MUTATED description from another schema"))
253+
.addProperty("name", new StringSchema().description("Widget name"));
254+
255+
ApiResponse responseA = new ApiResponse()
256+
.description("OK")
257+
.content(new Content().addMediaType("application/json",
258+
new MediaType().schema(widgetA)));
259+
ApiResponse responseB = new ApiResponse()
260+
.description("OK")
261+
.content(new Content().addMediaType("application/json",
262+
new MediaType().schema(widgetB)));
263+
264+
openapi.getPaths()
265+
.addPathItem("/a", new PathItem().get(
266+
new Operation().operationId("getA").responses(new ApiResponses().addApiResponse("200", responseA))))
267+
.addPathItem("/b", new PathItem().get(
268+
new Operation().operationId("getB").responses(new ApiResponses().addApiResponse("200", responseB))));
269+
270+
new InlineModelResolver().flatten(openapi);
271+
272+
Map<String, Schema> schemas = openapi.getComponents().getSchemas();
273+
assertNotNull("Widget schema must exist", schemas.get("Widget"));
274+
assertNull("Duplicate Widget_1 must not exist — shape-fingerprint dedup must fire", schemas.get("Widget_1"));
275+
}
276+
277+
@Test
278+
public void resolveInlineModelDeduplicatesMultipleRefsToSameExternalFile() {
279+
// Regression test: when the same external schema file is referenced from three separate
280+
// paths (simulating DeletionRequest appearing multiple times in the TAMS spec), the parser
281+
// may share the same Java Schema object across all three references. After the first
282+
// processing mutates the object (inline sub-schemas replaced with $refs), subsequent
283+
// encounters hash differently. The identity-based fast path in matchGenerated() must
284+
// recognise the same object reference and avoid registering a numbered duplicate.
285+
ParseOptions parseOptions = new ParseOptions();
286+
parseOptions.setResolve(true);
287+
parseOptions.setResolveResponses(true);
288+
OpenAPI openAPI = new OpenAPIParser().readLocation(
289+
"src/test/resources/3_0/inline-model-resolver-dedup/root.yaml",
290+
null, parseOptions).getOpenAPI();
291+
new InlineModelResolver().flatten(openAPI);
292+
293+
Map<String, Schema> schemas = openAPI.getComponents().getSchemas();
294+
assertNotNull(schemas.get("Deletion_Request"));
295+
assertNull("Duplicate Deletion_Request_1 must not exist", schemas.get("Deletion_Request_1"));
296+
}
297+
178298
@Test
179299
public void resolveInlineModel2DifferentInnerModelsWithSameTitle() {
180300
OpenAPI openapi = new OpenAPI();
@@ -1205,4 +1325,111 @@ public void doNotWrapSingleAllOfRefs() {
12051325
assertNotNull(allOfRefWithDescriptionAndReadonly.getAllOf());
12061326
assertEquals(numberRangeRef, ((Schema) allOfRefWithDescriptionAndReadonly.getAllOf().get(0)).get$ref());
12071327
}
1328+
1329+
@Test
1330+
public void resolveInlineModelDeduplicatesWhenParserMutatesPropertyTypes() {
1331+
// Regression test: the Swagger Parser shares a single resolved Schema object across all
1332+
// usages of an external type (e.g. storage-backend.json) and strips the 'type' field
1333+
// from its properties between processing passes. The first usage sees properties with
1334+
// type:"string"; the second usage sees the same object but with type stripped to null.
1335+
// IgnoreVolatileFieldsMixIn now strips 'type' in addition to 'description', so the
1336+
// structural-hash fallback in matchGenerated() must still unify them rather than creating
1337+
// a numbered variant.
1338+
OpenAPI openapi = new OpenAPI();
1339+
openapi.setComponents(new Components());
1340+
openapi.setPaths(new Paths());
1341+
1342+
// First inline schema: properties carry explicit type annotations (as delivered by the
1343+
// parser on first encounter of the shared external schema object)
1344+
StringSchema prop1First = new StringSchema();
1345+
prop1First.setDescription("The store type");
1346+
StringSchema prop2First = new StringSchema();
1347+
prop2First.setDescription("The provider");
1348+
Schema schemaFirstPass = new ObjectSchema()
1349+
.title("StorageBackend")
1350+
.description("A storage backend")
1351+
.addProperty("store_type", prop1First)
1352+
.addProperty("provider", prop2First);
1353+
1354+
// Second inline schema: same structure but 'type' has been stripped from properties,
1355+
// simulating the Swagger Parser mutating the shared resolved Schema object between passes.
1356+
StringSchema prop1Second = new StringSchema();
1357+
prop1Second.setType(null); // simulate parser stripping the type field
1358+
prop1Second.setDescription("The store type");
1359+
StringSchema prop2Second = new StringSchema();
1360+
prop2Second.setType(null);
1361+
prop2Second.setDescription("The provider");
1362+
Schema schemaSecondPass = new ObjectSchema()
1363+
.title("StorageBackend")
1364+
.description("A storage backend")
1365+
.addProperty("store_type", prop1Second)
1366+
.addProperty("provider", prop2Second);
1367+
1368+
openapi.getPaths()
1369+
.addPathItem("/a", new PathItem().get(new Operation().operationId("getA")
1370+
.responses(new ApiResponses().addApiResponse("200", new ApiResponse()
1371+
.description("OK")
1372+
.content(new Content().addMediaType("application/json",
1373+
new MediaType().schema(schemaFirstPass)))))))
1374+
.addPathItem("/b", new PathItem().get(new Operation().operationId("getB")
1375+
.responses(new ApiResponses().addApiResponse("200", new ApiResponse()
1376+
.description("OK")
1377+
.content(new Content().addMediaType("application/json",
1378+
new MediaType().schema(schemaSecondPass)))))));
1379+
1380+
new InlineModelResolver().flatten(openapi);
1381+
1382+
Map<String, Schema> schemas = openapi.getComponents().getSchemas();
1383+
assertNotNull("StorageBackend schema must exist", schemas.get("StorageBackend"));
1384+
assertNull("Duplicate StorageBackend_1 must not exist — type-stripped structural match must fire",
1385+
schemas.get("StorageBackend_1"));
1386+
}
1387+
1388+
@Test
1389+
public void deduplicateComponentsRemovesNumberedDuplicateOfTitledSchemaAndRewritesRefs() {
1390+
// Regression test: when flattening creates a numbered duplicate of a titled component
1391+
// (e.g. FlowSegment_1 alongside FlowSegment) because matchGenerated() missed the match
1392+
// due to T0-vs-T1 pre-populate timing, deduplicateComponents() must remove the duplicate
1393+
// and rewrite all $refs to it throughout the spec so the generated code only contains one
1394+
// class.
1395+
OpenAPI openapi = new OpenAPI();
1396+
openapi.setComponents(new Components());
1397+
openapi.setPaths(new Paths());
1398+
1399+
Schema canonical = new ObjectSchema()
1400+
.title("Widget")
1401+
.description("A widget")
1402+
.addProperty("name", new StringSchema());
1403+
1404+
// Duplicate: same title and structure — simulates what flatten() can produce when the
1405+
// pre-populate T0 signature no longer matches the T1 form of the same inline schema.
1406+
Schema duplicate = new ObjectSchema()
1407+
.title("Widget")
1408+
.description("A widget")
1409+
.addProperty("name", new StringSchema());
1410+
1411+
openapi.getComponents().addSchemas("Widget", canonical);
1412+
openapi.getComponents().addSchemas("Widget_1", duplicate);
1413+
1414+
// Path whose response references the numbered duplicate
1415+
openapi.getPaths().addPathItem("/widgets", new PathItem().get(
1416+
new Operation().operationId("getWidget").responses(
1417+
new ApiResponses().addApiResponse("200", new ApiResponse()
1418+
.description("OK")
1419+
.content(new Content().addMediaType("application/json",
1420+
new MediaType().schema(new Schema<>()
1421+
.$ref("#/components/schemas/Widget_1"))))))));
1422+
1423+
new InlineModelResolver().flatten(openapi);
1424+
1425+
Map<String, Schema> schemas = openapi.getComponents().getSchemas();
1426+
assertNotNull("Canonical Widget must survive deduplication", schemas.get("Widget"));
1427+
assertNull("Duplicate Widget_1 must be removed by deduplicateComponents()", schemas.get("Widget_1"));
1428+
1429+
// The $ref in the path response must have been rewritten to the canonical name
1430+
Schema responseSchema = openapi.getPaths().get("/widgets").getGet()
1431+
.getResponses().get("200").getContent().get("application/json").getSchema();
1432+
assertEquals("$ref must be rewritten from Widget_1 to Widget",
1433+
"#/components/schemas/Widget", responseSchema.get$ref());
1434+
}
12081435
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
title: Collection Item
2+
description: An item in a collection
3+
type: object
4+
properties:
5+
id:
6+
type: string
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
title: Container Mapping
2+
description: Defines the location of Flow essence data in a container track
3+
type: object
4+
properties:
5+
track_index:
6+
description: A zero-based track index
7+
type: integer
8+
minimum: 0
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
title: Deletion Request
2+
description: Describes an ongoing deletion request
3+
type: object
4+
required:
5+
- id
6+
- status
7+
properties:
8+
id:
9+
type: string
10+
format: uuid
11+
status:
12+
type: string
13+
enum: [created, done]
14+
error:
15+
description: Provides more information for the error status
16+
$ref: error.yaml
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
title: Error
2+
description: An API error response
3+
type: object
4+
properties:
5+
code:
6+
type: integer
7+
message:
8+
type: string
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
title: Flow Collection
2+
description: A collection of flows
3+
type: array
4+
items:
5+
type: object
6+
title: Flow Collection Item
7+
allOf:
8+
- $ref: collection-item.yaml
9+
- type: object
10+
properties:
11+
container_mapping:
12+
description: Describes the mapping of the Flow essence from this collection's container
13+
$ref: container-mapping.yaml
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
title: Flow Core
2+
description: Core flow properties
3+
type: object
4+
properties:
5+
id:
6+
type: string
7+
container_mapping:
8+
$ref: container-mapping.yaml
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
title: Video Flow
2+
description: A video flow
3+
type: object
4+
allOf:
5+
- $ref: flow-core.yaml
6+
- type: object
7+
properties:
8+
format:
9+
type: string
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
openapi: "3.1.0"
2+
info:
3+
title: Dedup Test
4+
version: "1.0"
5+
paths:
6+
/flows:
7+
get:
8+
operationId: getFlows
9+
responses:
10+
"200":
11+
description: OK
12+
content:
13+
application/json:
14+
schema:
15+
type: array
16+
items:
17+
$ref: flow-video.yaml
18+
/flow-collections:
19+
get:
20+
operationId: getFlowCollections
21+
responses:
22+
"200":
23+
description: OK
24+
content:
25+
application/json:
26+
schema:
27+
type: array
28+
items:
29+
$ref: flow-collection.yaml
30+
/deletions/{id}:
31+
get:
32+
operationId: getDeletion
33+
responses:
34+
"200":
35+
description: OK
36+
content:
37+
application/json:
38+
schema:
39+
$ref: deletion-request.yaml
40+
/deletions/{id}/status:
41+
get:
42+
operationId: getDeletionStatus
43+
responses:
44+
"200":
45+
description: OK
46+
content:
47+
application/json:
48+
schema:
49+
$ref: deletion-request.yaml
50+
/deletions:
51+
get:
52+
operationId: listDeletions
53+
responses:
54+
"200":
55+
description: OK
56+
content:
57+
application/json:
58+
schema:
59+
type: array
60+
items:
61+
$ref: deletion-request.yaml
62+
components: {}

0 commit comments

Comments
 (0)