From 7037dc82f81598076762f57c2c3e89eb704e147d Mon Sep 17 00:00:00 2001 From: Kejia Liu Date: Sat, 1 Aug 2026 13:13:57 +0800 Subject: [PATCH 1/3] fix: stop inline schemas from silently overwriting spec-defined models An inline request-body schema could silently replace a schema declared in components.schemas, deleting its properties from the generated code. No error, no warning -- the generated client just has the wrong contract for an endpoint that never used an inline schema: paths: /members/import: post: operationId: importMembers # inline body -> importMembers_request components: schemas: ImportMembersRequest: # mangles to the same model name properties: { declaredFieldA: ..., declaredFieldB: ... } Only one model was emitted, carrying the inline schema's properties; declaredFieldA/declaredFieldB disappeared, and the endpoint that $refs ImportMembersRequest silently serialised the wrong shape. InlineModelResolver.uniqueName() compared candidate names by raw string only. Generators mangle schema names before emitting types, so "importMembers_request" and "ImportMembersRequest" both become the model "ImportMembersRequest" while the raw keys look distinct -- no collision is detected and one model overwrites the other. Make the collision check normalization-aware: compare names ignoring separators and case, i.e. the way they will look after mangling. The existing name + "_" + count fallback then keeps both models, emitting ImportMembersRequest and ImportMembersRequest1. Regenerating all 787 sample configs produces no diff. --- .../codegen/InlineModelResolver.java | 39 ++++++++++++++++++- .../codegen/InlineModelResolverTest.java | 35 +++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) 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 4add57db345a..eaa026235167 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 @@ -924,13 +924,50 @@ private String uniqueName(final String name) { String uniqueName = name; int count = 0; while (true) { - if (!openAPI.getComponents().getSchemas().containsKey(uniqueName) && !uniqueNames.contains(uniqueName)) { + if (!isNameTaken(uniqueName)) { return uniqueName; } uniqueName = name + "_" + ++count; } } + /** + * Whether the candidate schema name collides with an existing one. + * + *

The comparison ignores separators and case, because generators mangle schema names before + * emitting types: the inline body schema {@code importMembers_request} and a spec-defined + * {@code ImportMembersRequest} both become the model {@code ImportMembersRequest}. Comparing the + * raw keys only, the two look distinct, the inline schema is registered under its own key, and + * later one model silently overwrites the other - the properties of the overwritten model + * disappear from the generated code. Treating them as a collision yields + * {@code importMembers_request_1} instead, so both models survive. + * + * @param candidate the candidate name + * @return true if the name is already taken + */ + private boolean isNameTaken(final String candidate) { + if (openAPI.getComponents().getSchemas().containsKey(candidate) || uniqueNames.contains(candidate)) { + return true; + } + + String normalized = normalizeNameForCollision(candidate); + for (String existing : openAPI.getComponents().getSchemas().keySet()) { + if (normalized.equals(normalizeNameForCollision(existing))) { + return true; + } + } + for (String existing : uniqueNames) { + if (normalized.equals(normalizeNameForCollision(existing))) { + return true; + } + } + return false; + } + + private String normalizeNameForCollision(final String name) { + return name.replace("_", "").toLowerCase(Locale.ROOT); + } + private void flattenProperties(OpenAPI openAPI, Map properties, String path) { if (properties == null) { return; diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/InlineModelResolverTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/InlineModelResolverTest.java index caf66155197d..284b9c436aa6 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/InlineModelResolverTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/InlineModelResolverTest.java @@ -1666,4 +1666,39 @@ public void deduplicateComponentsRewritesRefsOutsideComponentsSchemas() { assertRewritten("encoding headers", response.getContent().get("application/json") .getEncoding().get("ok").getHeaders().get("X-Encoding").getSchema()); } + + @Test + public void inlineSchemaDoesNotOverwriteSpecDefinedSchemaWithMangledSameName() { + // The inline request body schema is registered as "importMembers_request" while the spec + // also defines "ImportMembersRequest". Both mangle to the same model name, so without + // normalization-aware collision detection one silently overwrites the other and the + // properties of the overwritten model disappear from the generated code. + OpenAPI openAPI = new OpenAPI(); + openAPI.setComponents(new Components()); + openAPI.setPaths(new Paths()); + + openAPI.getComponents().addSchemas("ImportMembersRequest", new ObjectSchema() + .addProperty("declaredField", new StringSchema())); + + Schema inlineBody = new ObjectSchema().addProperty("inlineOnly", new StringSchema()); + openAPI.getPaths().addPathItem("/members/import", new PathItem().post(new Operation() + .operationId("importMembers") + .requestBody(new RequestBody().content(new Content() + .addMediaType("application/json", new MediaType().schema(inlineBody)))))); + + new InlineModelResolver().flatten(openAPI); + + // the spec-defined schema must survive untouched + Schema declared = openAPI.getComponents().getSchemas().get("ImportMembersRequest"); + assertNotNull(declared); + assertNotNull(declared.getProperties().get("declaredField")); + assertNull(declared.getProperties().get("inlineOnly")); + + // the inline schema must be registered under a name that does not mangle onto the + // spec-defined one + assertNull(openAPI.getComponents().getSchemas().get("importMembers_request")); + Schema inlined = openAPI.getComponents().getSchemas().get("importMembers_request_1"); + assertNotNull(inlined); + assertNotNull(inlined.getProperties().get("inlineOnly")); + } } From 17162a9f8905ba41d54b38eba2f9ada14eacaed4 Mon Sep 17 00:00:00 2001 From: Kejia Liu Date: Sat, 1 Aug 2026 14:30:44 +0800 Subject: [PATCH 2/3] fix: treat every non-alphanumeric character as a separator when detecting collisions Addresses review feedback on normalizeNameForCollision. sanitizeName turns '-', '.', ' ' and friends into '_' before the name is camelized, so a spec-defined `Import-Members-Request` mangles onto the same model as the inline `importMembers_request`. Stripping only '_' missed those, and was also narrower than the javadoc's "ignores separators and case". Strip every non-alphanumeric character instead. New test inlineSchemaDoesNotOverwriteSpecDefinedSchemaNamedWithOtherSeparators covers a hyphenated spec-defined name; it fails on the previous version. Regenerating all 787 sample configs still produces no diff. --- .../codegen/InlineModelResolver.java | 8 ++++- .../codegen/InlineModelResolverTest.java | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) 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 eaa026235167..05ec656d772c 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 @@ -37,8 +37,11 @@ import org.slf4j.LoggerFactory; import java.util.*; +import java.util.regex.Pattern; public class InlineModelResolver { + private static final Pattern NON_ALPHANUMERIC = Pattern.compile("[^a-zA-Z0-9]"); + private OpenAPI openAPI; private Map addedModels = new HashMap<>(); private Map generatedSignature = new HashMap<>(); @@ -965,7 +968,10 @@ private boolean isNameTaken(final String candidate) { } private String normalizeNameForCollision(final String name) { - return name.replace("_", "").toLowerCase(Locale.ROOT); + // Every non-alphanumeric character is a separator here, not just '_': sanitizeName turns + // '-', '.', ' ' and friends into '_' before the name is camelized, so `Import-Members-Request` + // and `importMembers_request` end up as the same model too. + return NON_ALPHANUMERIC.matcher(name).replaceAll("").toLowerCase(Locale.ROOT); } private void flattenProperties(OpenAPI openAPI, Map properties, String path) { diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/InlineModelResolverTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/InlineModelResolverTest.java index 284b9c436aa6..c7f9bdb94f26 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/InlineModelResolverTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/InlineModelResolverTest.java @@ -1701,4 +1701,35 @@ public void inlineSchemaDoesNotOverwriteSpecDefinedSchemaWithMangledSameName() { assertNotNull(inlined); assertNotNull(inlined.getProperties().get("inlineOnly")); } + + @Test + public void inlineSchemaDoesNotOverwriteSpecDefinedSchemaNamedWithOtherSeparators() { + // sanitizeName turns '-', '.', ' ' and friends into '_' before the name is camelized, so + // `Import-Members-Request` mangles onto the same model as the inline `importMembers_request`. + // Normalizing on '_' alone would miss this. + OpenAPI openAPI = new OpenAPI(); + openAPI.setComponents(new Components()); + openAPI.setPaths(new Paths()); + + openAPI.getComponents().addSchemas("Import-Members-Request", new ObjectSchema() + .addProperty("declaredField", new StringSchema())); + + Schema inlineBody = new ObjectSchema().addProperty("inlineOnly", new StringSchema()); + openAPI.getPaths().addPathItem("/members/import", new PathItem().post(new Operation() + .operationId("importMembers") + .requestBody(new RequestBody().content(new Content() + .addMediaType("application/json", new MediaType().schema(inlineBody)))))); + + new InlineModelResolver().flatten(openAPI); + + Schema declared = openAPI.getComponents().getSchemas().get("Import-Members-Request"); + assertNotNull(declared); + assertNotNull(declared.getProperties().get("declaredField")); + assertNull(declared.getProperties().get("inlineOnly")); + + assertNull(openAPI.getComponents().getSchemas().get("importMembers_request")); + Schema inlined = openAPI.getComponents().getSchemas().get("importMembers_request_1"); + assertNotNull(inlined); + assertNotNull(inlined.getProperties().get("inlineOnly")); + } } From dfff3c4e7d4d981b237b0fdcd792d3f562acdeff Mon Sep 17 00:00:00 2001 From: Kejia Liu Date: Sat, 1 Aug 2026 14:57:03 +0800 Subject: [PATCH 3/3] fix: compare names the way the model mangler does, instead of lowercasing Addresses review feedback that lowercasing over-matches. Names that differ only by letter case can stay distinct after mangling, so folding case flagged collisions that do not exist: importMembers_request -> ImportMembersRequest Import-Members-Request -> ImportMembersRequest (real collision) ImportMembersRequest -> ImportMembersRequest (real collision) Importmembersrequest -> Importmembersrequest (distinct!) IMPORTMEMBERSREQUEST -> IMPORTMEMBERSREQUEST (distinct!) The last two would have been pushed to _1 for no reason. Rather than hand-rolling a closer approximation, just run the names through the mangler itself -- camelize(sanitizeName(name)). That catches every real collision, cannot over-match by construction, and makes the javadoc's claim verifiable instead of aspirational. New test inlineSchemaKeepsItsNameWhenTheManglerKeepsNamesDistinct covers the false-positive direction; it fails on the previous version with "no real collision, the inline schema should keep its own name". Regenerating all 787 sample configs still produces no diff. --- .../codegen/InlineModelResolver.java | 25 +++++++++--------- .../codegen/InlineModelResolverTest.java | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 13 deletions(-) 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 05ec656d772c..c1b3456b7a6d 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 @@ -33,15 +33,14 @@ import io.swagger.v3.oas.models.responses.ApiResponses; import org.apache.commons.lang3.StringUtils; import org.openapitools.codegen.utils.ModelUtils; + +import static org.openapitools.codegen.utils.StringUtils.camelize; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.*; -import java.util.regex.Pattern; public class InlineModelResolver { - private static final Pattern NON_ALPHANUMERIC = Pattern.compile("[^a-zA-Z0-9]"); - private OpenAPI openAPI; private Map addedModels = new HashMap<>(); private Map generatedSignature = new HashMap<>(); @@ -937,14 +936,17 @@ private String uniqueName(final String name) { /** * Whether the candidate schema name collides with an existing one. * - *

The comparison ignores separators and case, because generators mangle schema names before - * emitting types: the inline body schema {@code importMembers_request} and a spec-defined - * {@code ImportMembersRequest} both become the model {@code ImportMembersRequest}. Comparing the - * raw keys only, the two look distinct, the inline schema is registered under its own key, and - * later one model silently overwrites the other - the properties of the overwritten model - * disappear from the generated code. Treating them as a collision yields + *

Names are compared as the default model mangler would emit them, i.e. + * {@code camelize(sanitizeName(name))}: the inline body schema {@code importMembers_request} and + * a spec-defined {@code ImportMembersRequest} both become the model {@code ImportMembersRequest}. + * Comparing the raw keys only, the two look distinct, the inline schema is registered under its + * own key, and later one model silently overwrites the other - the properties of the overwritten + * model disappear from the generated code. Treating them as a collision yields * {@code importMembers_request_1} instead, so both models survive. * + *

Mirroring the mangler rather than lowercasing keeps names that really do stay distinct + * apart: {@code Importmembersrequest} mangles to itself, not to {@code ImportMembersRequest}. + * * @param candidate the candidate name * @return true if the name is already taken */ @@ -968,10 +970,7 @@ private boolean isNameTaken(final String candidate) { } private String normalizeNameForCollision(final String name) { - // Every non-alphanumeric character is a separator here, not just '_': sanitizeName turns - // '-', '.', ' ' and friends into '_' before the name is camelized, so `Import-Members-Request` - // and `importMembers_request` end up as the same model too. - return NON_ALPHANUMERIC.matcher(name).replaceAll("").toLowerCase(Locale.ROOT); + return camelize(sanitizeName(name)); } private void flattenProperties(OpenAPI openAPI, Map properties, String path) { diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/InlineModelResolverTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/InlineModelResolverTest.java index c7f9bdb94f26..b9d0c5188303 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/InlineModelResolverTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/InlineModelResolverTest.java @@ -1732,4 +1732,30 @@ public void inlineSchemaDoesNotOverwriteSpecDefinedSchemaNamedWithOtherSeparator assertNotNull(inlined); assertNotNull(inlined.getProperties().get("inlineOnly")); } + + @Test + public void inlineSchemaKeepsItsNameWhenTheManglerKeepsNamesDistinct() { + // `Importmembersrequest` mangles to itself, not to `ImportMembersRequest`, so it is NOT a + // collision -- the inline schema must keep its own name rather than being pushed to _1. + OpenAPI openAPI = new OpenAPI(); + openAPI.setComponents(new Components()); + openAPI.setPaths(new Paths()); + + openAPI.getComponents().addSchemas("Importmembersrequest", new ObjectSchema() + .addProperty("declaredField", new StringSchema())); + + Schema inlineBody = new ObjectSchema().addProperty("inlineOnly", new StringSchema()); + openAPI.getPaths().addPathItem("/members/import", new PathItem().post(new Operation() + .operationId("importMembers") + .requestBody(new RequestBody().content(new Content() + .addMediaType("application/json", new MediaType().schema(inlineBody)))))); + + new InlineModelResolver().flatten(openAPI); + + assertNotNull(openAPI.getComponents().getSchemas().get("Importmembersrequest")); + Schema inlined = openAPI.getComponents().getSchemas().get("importMembers_request"); + assertNotNull("no real collision, the inline schema should keep its own name", inlined); + assertNotNull(inlined.getProperties().get("inlineOnly")); + assertNull(openAPI.getComponents().getSchemas().get("importMembers_request_1")); + } }