From 6f52995cefa2c98cfccfa04188936900210d5384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1chym=20Metli=C4=8Dka?= Date: Mon, 24 Aug 2026 20:44:59 +0200 Subject: [PATCH 01/11] fix: bypass schema and import mappings for forced schema generation --- .../openapitools/codegen/CodegenConfig.java | 13 ++ .../openapitools/codegen/DefaultCodegen.java | 60 ++++++- .../codegen/DefaultGenerator.java | 16 ++ .../languages/AbstractJavaCodegen.java | 24 ++- .../languages/AbstractKotlinCodegen.java | 21 ++- .../codegen/DefaultGeneratorTest.java | 49 +++--- .../ForcedGenerateSchemasSpringTest.java | 159 +++++++++++++++++ .../kotlin/AbstractKotlinCodegenTest.java | 56 +++++- .../ForcedGenerateSchemasKotlinTest.java | 164 ++++++++++++++++++ .../forced-generate-schema-mapping.yaml | 74 ++++++++ 10 files changed, 600 insertions(+), 36 deletions(-) create mode 100644 modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java create mode 100644 modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/ForcedGenerateSchemasKotlinTest.java create mode 100644 modules/openapi-generator/src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java index d9b3d8550e53..ef16cc409e62 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java @@ -174,6 +174,19 @@ default List divideOperationsByContentType(OpenAPI openAPI, String pa */ Set forcedGenerateSchemas(); + /** + * Signals whether the generator is currently building or emitting a model whose schema name + * is in {@link #forcedGenerateSchemas()}. While set, implementations should resolve forced + * schema names to their stock (unmapped) model names so the forced model and its references + * to other forced schemas are generated as if {@code schemaMappings}/{@code importMappings} + * did not apply. {@code typeMapping} and all other resolution are unaffected. + * + * @param generatingForcedModelContext {@code true} while a forced model is being processed. + */ + default void setGeneratingForcedModelContext(boolean generatingForcedModelContext) { + // no-op by default; implemented by DefaultCodegen + } + Map inlineSchemaNameMapping(); Map inlineSchemaOption(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java index 085f1d981597..98f430439090 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java @@ -187,6 +187,11 @@ public class DefaultCodegen implements CodegenConfig { // a set of schema names that must be generated even when listed in schemaMappings or importMappings. // Use CodegenConstants.FORCE_GENERATE_ALL_SCHEMAS ("*") to force-generate all mapped schemas. protected Set forcedGenerateSchemas = new HashSet<>(); + // transient flag set by DefaultGenerator while it is building/emitting a model whose schema + // name is in forcedGenerateSchemas. When true, name resolution for forced schemas bypasses + // schemaMapping/importMapping so the stock model (and its references to other forced schemas) + // is generated "as if" those mappings did not exist. See toModelName overrides. + protected boolean generatingForcedModelContext = false; // a map to store the mapping between inline schema and the name provided by the user protected Map inlineSchemaNameMapping = new HashMap<>(); // a map to store the inline schema naming conventions @@ -643,7 +648,12 @@ public Map postProcessAllModels(Map objs) List> imports = modelsAttrs.getImports(); for (ModelMap implmo : modelsAttrs.getModels()) { CodegenModel implcm = implmo.getModel(); - String modelName = toModelName(implcm.name); + // Resolve the implementor's model name the same way the oneOf interface recorded + // its members. A force-generated implementor was named under the forced context + // (stock name), so it must be resolved that way here too; otherwise its stock + // name would not match the FQN mapping and the oneOf interface would never be + // attached to it. + final String modelName = toModelNameInForcedContextIfForced(implcm.name); if (additionalDataMap.containsKey(modelName)) { additionalDataMap.get(modelName).addToImplementor(this, implcm, imports, addOneOfInterfaceImports); } @@ -694,7 +704,7 @@ protected Map getModelNameToSchemaCache() { public Map getAllModels(Map objs) { Map allModels = new LinkedHashMap<>(); for (Entry entry : objs.entrySet()) { - String modelName = toModelName(entry.getKey()); + String modelName = toModelNameInForcedContextIfForced(entry.getKey()); List models = entry.getValue().getModels(); for (ModelMap mo : models) { CodegenModel cm = mo.getModel(); @@ -1634,6 +1644,52 @@ public Set forcedGenerateSchemas() { return forcedGenerateSchemas; } + @Override + public void setGeneratingForcedModelContext(boolean generatingForcedModelContext) { + this.generatingForcedModelContext = generatingForcedModelContext; + } + + /** + * Returns {@code true} when {@code name} is force-generated, i.e. it is listed in + * {@link #forcedGenerateSchemas} or the {@link CodegenConstants#FORCE_GENERATE_ALL_SCHEMAS} + * wildcard is present. Independent of {@link #generatingForcedModelContext}. + */ + protected boolean isForcedSchema(String name) { + return forcedGenerateSchemas.contains(CodegenConstants.FORCE_GENERATE_ALL_SCHEMAS) + || forcedGenerateSchemas.contains(name); + } + + /** + * Returns {@code true} when the named schema should be resolved to its stock (unmapped) model + * name. This is the case while {@link #generatingForcedModelContext} is set (i.e. we are + * building or emitting a forced model) and {@code name} is itself force-generated (either + * listed explicitly or via the {@link CodegenConstants#FORCE_GENERATE_ALL_SCHEMAS} wildcard). + */ + protected boolean isForcedModelNameResolution(String name) { + return generatingForcedModelContext && isForcedSchema(name); + } + + /** + * Resolves {@code schemaName} to its model name, forcing the stock (unmapped) name when the + * schema is force-generated. Force-generated models are named under the forced context (so a + * mapped schema like {@code Content} becomes the stock {@code ApiContent} rather than its FQN + * mapping). Any code that indexes or looks models up by name must use the same resolution, so + * that stock-named references (e.g. {@code oneOf} members) match their model. Non-forced + * schemas resolve exactly as {@link #toModelName(String)} would. + */ + protected String toModelNameInForcedContextIfForced(String schemaName) { + if (!isForcedSchema(schemaName)) { + return toModelName(schemaName); + } + final boolean prev = this.generatingForcedModelContext; + this.generatingForcedModelContext = true; + try { + return toModelName(schemaName); + } finally { + this.generatingForcedModelContext = prev; + } + } + @Override public Map inlineSchemaNameMapping() { return inlineSchemaNameMapping; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java index 6636187fec0a..0181f512bec1 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java @@ -477,6 +477,10 @@ void generateModels(List files, List allModels, List unu // process models only for (String name : modelKeys) { processedModels.add(name); + // resolve forced schema names to their stock (unmapped) model names while this model + // is being built, so a forced model and its references to other forced schemas ignore + // schemaMapping/importMapping. Non-forced models keep the mapping. + config.setGeneratingForcedModelContext(!isNotForcedGenerate(name)); try { //don't generate models that have an import mapping or are in the list of schemas to always generate if (config.schemaMapping().containsKey(name) && isNotForcedGenerate(name)) { @@ -525,9 +529,16 @@ void generateModels(List files, List allModels, List unu ModelsMap models = processModels(config, schemaMap); models.put("classname", config.toModelName(name)); models.putAll(config.additionalProperties()); + // Reset before inserting into allProcessedModels: that map is a TreeMap ordered by + // config.toModelName(), which is context-sensitive for forced schemas. Inserting + // (and later getting) must use the stable, non-forced naming or the tree ordering + // becomes inconsistent and lookups can miss keys. + config.setGeneratingForcedModelContext(false); allProcessedModels.put(name, models); } catch (Exception e) { throw new RuntimeException("Could not process model '" + name + "'" + ".Please make sure that your schema is correct!", e); + } finally { + config.setGeneratingForcedModelContext(false); } } @@ -559,6 +570,9 @@ void generateModels(List files, List allModels, List unu for (String modelName : allProcessedModels.keySet()) { ModelsMap models = allProcessedModels.get(modelName); models.put("modelPackage", config.modelPackage()); + // keep forced schema names resolving to stock names while the file (and its filename) + // is emitted for a forced model. + config.setGeneratingForcedModelContext(!isNotForcedGenerate(modelName)); try { //don't generate models that have a schema mapping or are in the list of schemas to always generate if (config.schemaMapping().containsKey(modelName) && isNotForcedGenerate(modelName)) { @@ -606,6 +620,8 @@ void generateModels(List files, List allModels, List unu } catch (Exception e) { throw new RuntimeException("Could not generate model '" + modelName + "'", e); + } finally { + config.setGeneratingForcedModelContext(false); } } if (GlobalSettings.getProperty("debugModels") != null) { diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java index 554f98183fc7..96ec760d8b24 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java @@ -1036,15 +1036,21 @@ public String toModelName(final String name) { return modelNameMapping.get(name); } + // When resolving a forced schema (see forcedGenerateSchemas), bypass schemaMapping and the + // name cache so the stock model name is produced "as if" that mapping did not exist. + // typeMapping and every other resolution rule are unaffected. + final boolean forcedResolution = isForcedModelNameResolution(name); + // We need to check if schema-mapping has a different model for this class, so we use it // instead of the auto-generated one. - if (schemaMapping.containsKey(name)) { + if (!forcedResolution && schemaMapping.containsKey(name)) { return schemaMapping.get(name); } - // memoization + // memoization (skipped for forced resolution so the stock name is never cached under a key + // that non-forced references must still resolve to the mapped name) String origName = name; - if (schemaKeyToModelNameCache.containsKey(origName)) { + if (!forcedResolution && schemaKeyToModelNameCache.containsKey(origName)) { return schemaKeyToModelNameCache.get(origName); } @@ -1068,7 +1074,9 @@ public String toModelName(final String name) { // model name cannot use reserved keyword, e.g. return if (isReservedWord(camelizedName)) { final String modelName = "Model" + camelizedName; - schemaKeyToModelNameCache.put(origName, modelName); + if (!forcedResolution) { + schemaKeyToModelNameCache.put(origName, modelName); + } LOGGER.warn("{} (reserved word) cannot be used as model name. Renamed to {}", camelizedName, modelName); return modelName; } @@ -1076,13 +1084,17 @@ public String toModelName(final String name) { // model name starts with number if (camelizedName.matches("^\\d.*")) { final String modelName = "Model" + camelizedName; // e.g. 200Response => Model200Response (after camelize) - schemaKeyToModelNameCache.put(origName, modelName); + if (!forcedResolution) { + schemaKeyToModelNameCache.put(origName, modelName); + } LOGGER.warn("{} (model name starts with number) cannot be used as model name. Renamed to {}", name, modelName); return modelName; } - schemaKeyToModelNameCache.put(origName, camelizedName); + if (!forcedResolution) { + schemaKeyToModelNameCache.put(origName, camelizedName); + } return camelizedName; } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java index d62c812ad86e..15ed7617813b 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java @@ -797,8 +797,14 @@ public String toModelName(final String name) { return modelNameMapping.get(name); } - // memoization - if (schemaKeyToModelNameCache.containsKey(name)) { + // When resolving a forced schema (see forcedGenerateSchemas), bypass schemaMapping, + // importMapping and the name cache so the stock model name is produced "as if" those + // mappings did not exist. typeMapping and every other resolution rule are unaffected. + final boolean forcedResolution = isForcedModelNameResolution(name); + + // memoization (skipped for forced resolution so the stock name is never cached under a key + // that non-forced references must still resolve to the mapped name) + if (!forcedResolution && schemaKeyToModelNameCache.containsKey(name)) { return schemaKeyToModelNameCache.get(name); } @@ -808,13 +814,13 @@ public String toModelName(final String name) { } // If schemaMapping contains name, assume this is a legitimate model name. - if (schemaMapping.containsKey(name)) { + if (!forcedResolution && schemaMapping.containsKey(name)) { return schemaMapping.get(name); } // TODO review importMapping below as we've added schema mapping support // If importMapping contains name, assume this is a legitimate model name. - if (importMapping.containsKey(name)) { + if (!forcedResolution && importMapping.containsKey(name)) { return importMapping.get(name); } @@ -849,8 +855,11 @@ public String toModelName(final String name) { return modelName; } - schemaKeyToModelNameCache.put(name, titleCase(modifiedName)); - return schemaKeyToModelNameCache.get(name); + final String resolvedModelName = titleCase(modifiedName); + if (!forcedResolution) { + schemaKeyToModelNameCache.put(name, resolvedModelName); + } + return resolvedModelName; } /** diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java index 2119ec1c2853..44b9e0d57396 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java @@ -398,10 +398,13 @@ public void supportCustomTemplateEngine() throws IOException { * Verifies that a schema listed in schemaMappings is skipped by default, but is generated * when it also appears in forcedGenerateSchemas. * - * When a schema is in schemaMappings, the generator renames the model using the mapped value. - * For example, mapping "Category" -> "ExternalCategory" means the generated file is - * ExternalCategory.java. Part 2 verifies that this file IS written when forcedGenerateSchemas - * contains "Category", whereas Part 1 verifies that NO such file exists without it. + * A force-generated schema is emitted "as if" the schemaMapping did not exist, i.e. under its + * stock (unmapped) model name. For example, mapping "Category" -> "ExternalCategory" normally + * suppresses the model, but force-generating "Category" produces the stock Category.java (NOT + * ExternalCategory.java). This keeps the output valid even for fully-qualified mapping targets, + * whose dotted names could not be used as a class/file name. Part 2 verifies the stock file IS + * written when forcedGenerateSchemas contains "Category", whereas Part 1 verifies that NO model + * file exists without it. */ @Test public void forcedGenerateSchemaOverridesSchemaMappingSkip() throws IOException { @@ -439,8 +442,8 @@ public void forcedGenerateSchemaOverridesSchemaMappingSkip() throws IOException } // --- Part 2: forcedGenerateSchemas must force generation despite schemaMapping --- - // The Java generator resolves the model name through schemaMapping (Category -> ExternalCategory), - // so the output file is ExternalCategory.java, not Category.java. + // A force-generated schema is emitted under its stock (unmapped) name, "as if" the mapping + // did not exist, so the output file is Category.java, not ExternalCategory.java. Path target2 = Files.createTempDirectory("test-forced-gen-force"); try { final CodegenConfigurator configurator = new CodegenConfigurator() @@ -466,18 +469,22 @@ public void forcedGenerateSchemaOverridesSchemaMappingSkip() throws IOException List files = generator.opts(clientOptInput).generate(); Assert.assertTrue( - files.stream().anyMatch(f -> f.getPath().replace('\\', '/').endsWith(mappedModelRelPath)), - "ExternalCategory.java MUST be generated when Category is in both schemaMappings and forcedGenerateSchemas"); + files.stream().anyMatch(f -> f.getPath().replace('\\', '/').endsWith(originalModelRelPath)), + "Category.java (stock name) MUST be generated when Category is in both schemaMappings and forcedGenerateSchemas"); Assert.assertTrue( - new File(target2.toFile(), mappedModelRelPath).exists(), - "ExternalCategory.java MUST exist on disk when forcedGenerateSchemas overrides schemaMappings"); + new File(target2.toFile(), originalModelRelPath).exists(), + "Category.java MUST exist on disk when forcedGenerateSchemas overrides schemaMappings"); + Assert.assertFalse( + files.stream().anyMatch(f -> f.getPath().replace('\\', '/').endsWith(mappedModelRelPath)), + "ExternalCategory.java (mapped name) must NOT be generated: forced schemas bypass the mapping"); } finally { target2.toFile().deleteOnExit(); } // --- Part 3: wildcard "*" must force-generate ALL schemas suppressed by schemaMappings --- // Two schemas are mapped (Category->ExternalCategory, Tag->ExternalTag). - // Adding only "*" (FORCE_GENERATE_ALL_SCHEMAS) to forcedGenerateSchemas must cause both to be generated. + // Adding only "*" (FORCE_GENERATE_ALL_SCHEMAS) to forcedGenerateSchemas must cause both to be + // generated under their stock names (Category.java, Tag.java), bypassing the mapping. Path target3 = Files.createTempDirectory("test-forced-gen-wildcard"); try { final CodegenConfigurator configurator = new CodegenConfigurator() @@ -498,21 +505,21 @@ public void forcedGenerateSchemaOverridesSchemaMappingSkip() throws IOException List files = generator.opts(clientOptInput).generate(); - final String externalCategoryRelPath = "src/main/java/org/openapitools/client/model/ExternalCategory.java"; - final String externalTagRelPath = "src/main/java/org/openapitools/client/model/ExternalTag.java"; + final String categoryRelPath = "src/main/java/org/openapitools/client/model/Category.java"; + final String tagRelPath = "src/main/java/org/openapitools/client/model/Tag.java"; Assert.assertTrue( - files.stream().anyMatch(f -> f.getPath().replace('\\', '/').endsWith(externalCategoryRelPath)), - "ExternalCategory.java MUST be generated when wildcard \"*\" is in forcedGenerateSchemas"); + files.stream().anyMatch(f -> f.getPath().replace('\\', '/').endsWith(categoryRelPath)), + "Category.java (stock name) MUST be generated when wildcard \"*\" is in forcedGenerateSchemas"); Assert.assertTrue( - files.stream().anyMatch(f -> f.getPath().replace('\\', '/').endsWith(externalTagRelPath)), - "ExternalTag.java MUST be generated when wildcard \"*\" is in forcedGenerateSchemas"); + files.stream().anyMatch(f -> f.getPath().replace('\\', '/').endsWith(tagRelPath)), + "Tag.java (stock name) MUST be generated when wildcard \"*\" is in forcedGenerateSchemas"); Assert.assertTrue( - new File(target3.toFile(), externalCategoryRelPath).exists(), - "ExternalCategory.java MUST exist on disk when wildcard \"*\" is used"); + new File(target3.toFile(), categoryRelPath).exists(), + "Category.java MUST exist on disk when wildcard \"*\" is used"); Assert.assertTrue( - new File(target3.toFile(), externalTagRelPath).exists(), - "ExternalTag.java MUST exist on disk when wildcard \"*\" is used"); + new File(target3.toFile(), tagRelPath).exists(), + "Tag.java MUST exist on disk when wildcard \"*\" is used"); } finally { target3.toFile().deleteOnExit(); } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java new file mode 100644 index 000000000000..cf6bf5ffe9b5 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java @@ -0,0 +1,159 @@ +package org.openapitools.codegen.java.spring; + +import org.openapitools.codegen.ClientOptInput; +import org.openapitools.codegen.CodegenConstants; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.TestUtils; +import org.openapitools.codegen.languages.SpringCodegen; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; + +import static org.openapitools.codegen.TestUtils.assertFileContains; +import static org.openapitools.codegen.TestUtils.assertFileNotContains; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +/** + * End-to-end coverage for {@code forcedGenerateSchemas} combined with fully-qualified + * {@code schemaMappings} on the java-spring generator. This is the Java counterpart of + * {@link org.openapitools.codegen.kotlin.ForcedGenerateSchemasKotlinTest} and guards the same + * behavior for the Java model-name resolution path ({@code AbstractJavaCodegen.toModelName}): + *
    + *
  • forced declarations use the prefixed stock name (never the dotted FQN, invalid Java);
  • + *
  • references between forced schemas resolve to the stock names (a forced ApiCircle + * implements the generated ApiShape interface rather than the handwritten one);
  • + *
  • references from non-forced models still honor the mapping (production stays unchanged);
  • + *
  • type/primitive resolution is preserved (Label: string -> String).
  • + *
+ */ +public class ForcedGenerateSchemasSpringTest { + + private static final String SPEC = "src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml"; + private static final String MODEL_DIR = "/src/main/java/org/openapitools/model/"; + + private File generate(File output, String... forcedSchemas) { + final SpringCodegen codegen = new SpringCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.setModelNamePrefix("Api"); + codegen.setUseOneOfInterfaces(true); + codegen.setLegacyDiscriminatorBehavior(false); + + codegen.schemaMapping().put("Widget", "com.example.mapped.Widget"); + codegen.schemaMapping().put("Group", "com.example.mapped.Group"); + codegen.schemaMapping().put("Shape", "com.example.mapped.Shape"); + codegen.schemaMapping().put("Circle", "com.example.mapped.Circle"); + codegen.schemaMapping().put("Square", "com.example.mapped.Square"); + + codegen.forcedGenerateSchemas().addAll(Arrays.asList(forcedSchemas)); + + DefaultGenerator generator = new DefaultGenerator(); + generator.setGenerateMetadata(false); + generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true"); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_TESTS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_DOCS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.LEGACY_DISCRIMINATOR_BEHAVIOR, "false"); + + generator.opts(new ClientOptInput() + .openAPI(TestUtils.parseSpec(SPEC)) + .config(codegen)) + .generate(); + + return new File(output, MODEL_DIR); + } + + @Test + public void forcedFqnMappedSchemasAreGeneratedWithValidStockNames() throws IOException { + File output = Files.createTempDirectory("forced-gen-spring").toFile().getCanonicalFile(); + output.deleteOnExit(); + + File modelDir = generate(output, "Widget", "Group", "Shape", "Circle", "Square"); + + // The stock, Api-prefixed models are emitted. + for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiShape", "ApiCircle", "ApiSquare")) { + assertTrue(new File(modelDir, name + ".java").exists(), name + ".java must be generated"); + } + + // No file was emitted under the dotted FQN name (which would have been invalid Java). + for (String name : Arrays.asList("Widget", "Group", "Shape", "Circle", "Square")) { + assertFalse(new File(modelDir, "com.example.mapped." + name + ".java").exists(), + "no file must be emitted with the dotted FQN name for " + name); + } + + // No forced model declaration or reference leaks the mapped FQN. + for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiShape", "ApiCircle", "ApiSquare")) { + assertFileNotContains(Paths.get(modelDir + File.separator + name + ".java"), "com.example.mapped."); + } + } + + @Test + public void forcedSchemasReferenceEachOtherByStockName() throws IOException { + File output = Files.createTempDirectory("forced-gen-spring").toFile().getCanonicalFile(); + output.deleteOnExit(); + + File modelDir = generate(output, "Widget", "Group", "Shape", "Circle", "Square"); + + // Intra-forced references resolve to the stock names. + assertFileContains(Paths.get(modelDir + File.separator + "ApiWidget.java"), "ApiGroup"); + assertFileContains(Paths.get(modelDir + File.separator + "ApiGroup.java"), "ApiShape"); + // Forced Circle/Square implement the generated ApiShape interface, not the handwritten one. + assertFileContains(Paths.get(modelDir + File.separator + "ApiShape.java"), "ApiCircle"); + assertFileContains(Paths.get(modelDir + File.separator + "ApiShape.java"), "ApiSquare"); + assertFileContains(Paths.get(modelDir + File.separator + "ApiCircle.java"), "implements ApiShape"); + assertFileContains(Paths.get(modelDir + File.separator + "ApiSquare.java"), "implements ApiShape"); + // The generated ApiShape interface carries the discriminator @JsonSubTypes with stock names. + assertFileContains(Paths.get(modelDir + File.separator + "ApiShape.java"), + "@JsonSubTypes.Type(value = ApiCircle.class, name = \"circle\")"); + assertFileContains(Paths.get(modelDir + File.separator + "ApiShape.java"), + "@JsonSubTypes.Type(value = ApiSquare.class, name = \"square\")"); + } + + @Test + public void typeMappingIsPreservedForForcedSchemas() throws IOException { + File output = Files.createTempDirectory("forced-gen-spring").toFile().getCanonicalFile(); + output.deleteOnExit(); + + File modelDir = generate(output, "Widget", "Group", "Shape", "Circle", "Square"); + + // Label (type: string) still resolves to a Java String inside the forced ApiCircle — the + // string alias must not surface as its own ApiLabel type. + Path circle = Paths.get(modelDir + File.separator + "ApiCircle.java"); + assertFileContains(circle, "private String label;"); + assertFileNotContains(circle, "ApiLabel"); + } + + @Test + public void nonForcedModelStillHonorsTheMapping() throws IOException { + File output = Files.createTempDirectory("forced-gen-spring").toFile().getCanonicalFile(); + output.deleteOnExit(); + + File modelDir = generate(output, "Widget", "Group", "Shape", "Circle", "Square"); + + // Container is neither mapped nor forced: its reference to Widget must resolve to the mapped + // FQN, proving the forced context does not leak into non-forced models. + assertFileContains(Paths.get(modelDir + File.separator + "ApiContainer.java"), "com.example.mapped.Widget"); + } + + @Test + public void wildcardForcesAllMappedSchemas() throws IOException { + File output = Files.createTempDirectory("forced-gen-spring").toFile().getCanonicalFile(); + output.deleteOnExit(); + + File modelDir = generate(output, CodegenConstants.FORCE_GENERATE_ALL_SCHEMAS); + + for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiShape", "ApiCircle", "ApiSquare")) { + assertTrue(new File(modelDir, name + ".java").exists(), name + ".java must be generated with the wildcard"); + } + // With the wildcard, EVERY schema is force-generated — including the otherwise non-mapped + // Container — so all references resolve to stock names and none leaks the mapped FQN. + assertFileContains(Paths.get(modelDir + File.separator + "ApiContainer.java"), "ApiWidget"); + assertFileNotContains(Paths.get(modelDir + File.separator + "ApiContainer.java"), "com.example.mapped.Widget"); + } +} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/AbstractKotlinCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/AbstractKotlinCodegenTest.java index e155b0b00ff0..1bfc95138f34 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/AbstractKotlinCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/AbstractKotlinCodegenTest.java @@ -34,8 +34,62 @@ public class AbstractKotlinCodegenTest { private AbstractKotlinCodegen codegen; + @Test + public void forcedGenerateSchemaBypassesSchemaMappingForDeclarationAndCache() { + codegen.setModelNamePrefix("Api"); + codegen.schemaMapping().put("Widget", "com.example.mapped.Widget"); + codegen.forcedGenerateSchemas().add("Widget"); + + // Without the forced context (i.e. a reference from a non-forced model), the mapped FQN is + // returned so production references keep resolving to the hand-written class. + assertEquals(codegen.toModelName("Widget"), "com.example.mapped.Widget"); + + // While generating the forced model, the stock (prefixed) name is produced instead. + codegen.setGeneratingForcedModelContext(true); + assertEquals(codegen.toModelName("Widget"), "ApiWidget"); + + // Turning the context back off restores the mapped name: the forced resolution must not + // poison the name cache under the "Widget" key. + codegen.setGeneratingForcedModelContext(false); + assertEquals(codegen.toModelName("Widget"), "com.example.mapped.Widget"); + } + + @Test + public void forcedGenerateSchemaWildcardBypassesSchemaMapping() { + codegen.setModelNamePrefix("Api"); + codegen.schemaMapping().put("Widget", "com.example.mapped.Widget"); + codegen.forcedGenerateSchemas().add(CodegenConstants.FORCE_GENERATE_ALL_SCHEMAS); + + codegen.setGeneratingForcedModelContext(true); + assertEquals(codegen.toModelName("Widget"), "ApiWidget"); + } + + @Test + public void forcedGenerateContextOnlyAffectsForcedSchemas() { + codegen.setModelNamePrefix("Api"); + codegen.schemaMapping().put("Widget", "com.example.mapped.Widget"); + codegen.schemaMapping().put("Other", "com.example.other.Other"); + codegen.forcedGenerateSchemas().add("Widget"); + + codegen.setGeneratingForcedModelContext(true); + // Widget is forced -> stock name. + assertEquals(codegen.toModelName("Widget"), "ApiWidget"); + // Other is mapped but NOT forced -> mapping still honored even inside a forced context. + assertEquals(codegen.toModelName("Other"), "com.example.other.Other"); + } + + @Test + public void forcedGeneratePreservesImportMappingBypass() { + codegen.setModelNamePrefix("Api"); + codegen.importMapping().put("Widget", "com.example.mapped.Widget"); + codegen.forcedGenerateSchemas().add("Widget"); + + assertEquals(codegen.toModelName("Widget"), "com.example.mapped.Widget"); + codegen.setGeneratingForcedModelContext(true); + assertEquals(codegen.toModelName("Widget"), "ApiWidget"); + } + /** - * In TEST-NG, test class (and its fields) is only constructed once (vs. for every test in Jupiter), * using @BeforeMethod to have a fresh codegen mock for each test */ @BeforeMethod diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/ForcedGenerateSchemasKotlinTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/ForcedGenerateSchemasKotlinTest.java new file mode 100644 index 000000000000..167aefe02ac7 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/ForcedGenerateSchemasKotlinTest.java @@ -0,0 +1,164 @@ +package org.openapitools.codegen.kotlin; + +import org.openapitools.codegen.ClientOptInput; +import org.openapitools.codegen.CodegenConstants; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.TestUtils; +import org.openapitools.codegen.languages.KotlinSpringServerCodegen; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; + +import static org.openapitools.codegen.TestUtils.assertFileContains; +import static org.openapitools.codegen.TestUtils.assertFileNotContains; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +/** + * End-to-end coverage for {@code forcedGenerateSchemas} combined with fully-qualified + * {@code schemaMappings} on the kotlin-spring generator. + *

+ * These tests use a generic model graph where Widget/Group/Shape/Circle/Square are mapped to + * hand-written classes but are also force-generated. The stock {@code ApiXxx} models must be + * emitted "as if" the mappings did not apply: + *

    + *
  • declarations use the prefixed stock name (never the dotted FQN, which is invalid Kotlin);
  • + *
  • references between forced schemas resolve to the stock names (so a forced ApiCircle + * implements the generated ApiShape rather than the hand-written sealed interface);
  • + *
  • references from non-forced models still honor the mapping (production stays unchanged);
  • + *
  • typeMapping / primitive resolution is preserved (Label: string -> kotlin.String).
  • + *
+ * The pre-existing {@code DefaultGeneratorTest.forcedGenerateSchemaOverridesSchemaMappingSkip} did + * not catch the underlying bug because it used the Java generator, a simple (non-FQN) mapped name, + * a leaf schema, and asserted only file existence. + */ +public class ForcedGenerateSchemasKotlinTest { + + private static final String SPEC = "src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml"; + private static final String MODEL_DIR = "/src/main/kotlin/org/openapitools/model/"; + + private File generate(File output, String... forcedSchemas) { + final KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); codegen.setModelNamePrefix("Api"); + codegen.setUseOneOfInterfaces(true); + codegen.setLegacyDiscriminatorBehavior(false); + + codegen.schemaMapping().put("Widget", "com.example.mapped.Widget"); + codegen.schemaMapping().put("Group", "com.example.mapped.Group"); + codegen.schemaMapping().put("Shape", "com.example.mapped.Shape"); + codegen.schemaMapping().put("Circle", "com.example.mapped.Circle"); + codegen.schemaMapping().put("Square", "com.example.mapped.Square"); + + codegen.forcedGenerateSchemas().addAll(Arrays.asList(forcedSchemas)); + + DefaultGenerator generator = new DefaultGenerator(); + generator.setGenerateMetadata(false); + generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true"); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_TESTS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_DOCS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.LEGACY_DISCRIMINATOR_BEHAVIOR, "false"); + + generator.opts(new ClientOptInput() + .openAPI(TestUtils.parseSpec(SPEC)) + .config(codegen)) + .generate(); + + return new File(output, MODEL_DIR); + } + + @Test + public void forcedFqnMappedSchemasAreGeneratedWithValidStockNames() throws IOException { + File output = Files.createTempDirectory("forced-gen").toFile().getCanonicalFile(); + output.deleteOnExit(); + + File modelDir = generate(output, "Widget", "Group", "Shape", "Circle", "Square"); + + // The stock, Api-prefixed models are emitted. + for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiShape", "ApiCircle", "ApiSquare")) { + assertTrue(new File(modelDir, name + ".kt").exists(), name + ".kt must be generated"); + } + + // No file was emitted under the dotted FQN name (which would have been invalid Kotlin). + for (String name : Arrays.asList("Widget", "Group", "Shape", "Circle", "Square")) { + assertFalse(new File(modelDir, "com.example.mapped." + name + ".kt").exists(), + "no file must be emitted with the dotted FQN name for " + name); + } + + // No forced model declaration or reference leaks the mapped FQN. + for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiShape", "ApiCircle", "ApiSquare")) { + assertFileNotContains(Paths.get(modelDir + File.separator + name + ".kt"), "com.example.mapped."); + } + } + + @Test + public void forcedSchemasReferenceEachOtherByStockName() throws IOException { + File output = Files.createTempDirectory("forced-gen").toFile().getCanonicalFile(); + output.deleteOnExit(); + + File modelDir = generate(output, "Widget", "Group", "Shape", "Circle", "Square"); + + // Intra-forced references resolve to the stock names. + assertFileContains(Paths.get(modelDir + File.separator + "ApiWidget.kt"), "ApiGroup"); + assertFileContains(Paths.get(modelDir + File.separator + "ApiGroup.kt"), "ApiShape"); + // Forced Circle/Square implement the generated ApiShape, not the handwritten sealed one. + assertFileContains(Paths.get(modelDir + File.separator + "ApiShape.kt"), "ApiCircle"); + assertFileContains(Paths.get(modelDir + File.separator + "ApiShape.kt"), "ApiSquare"); + assertFileContains(Paths.get(modelDir + File.separator + "ApiCircle.kt"), "ApiShape"); + // The discriminator property must be marked as inherited (override) and default to the + // mapping value, exactly as it would if no schemaMapping existed. Missing the `override` + // would fail to compile against the `type` declared on the sealed ApiShape interface. + assertFileContains(Paths.get(modelDir + File.separator + "ApiCircle.kt"), + "override val type: kotlin.String = \"circle\""); + assertFileContains(Paths.get(modelDir + File.separator + "ApiSquare.kt"), + "override val type: kotlin.String = \"square\""); + } + + @Test + public void typeMappingIsPreservedForForcedSchemas() throws IOException { + File output = Files.createTempDirectory("forced-gen").toFile().getCanonicalFile(); + output.deleteOnExit(); + + File modelDir = generate(output, "Widget", "Group", "Shape", "Circle", "Square"); + + // Label (type: string) still resolves to a Kotlin String inside the forced ApiCircle. + Path circle = Paths.get(modelDir + File.separator + "ApiCircle.kt"); + assertFileContains(circle, "label"); + assertFileContains(circle, "String"); + assertFileNotContains(circle, "Label"); + } + + @Test + public void nonForcedModelStillHonorsTheMapping() throws IOException { + File output = Files.createTempDirectory("forced-gen").toFile().getCanonicalFile(); + output.deleteOnExit(); + + File modelDir = generate(output, "Widget", "Group", "Shape", "Circle", "Square"); + + // Container is neither mapped nor forced: its reference to Widget must resolve to the mapped + // FQN, proving the forced context does not leak into non-forced models. + assertFileContains(Paths.get(modelDir + File.separator + "ApiContainer.kt"), "com.example.mapped.Widget"); + } + + @Test + public void wildcardForcesAllMappedSchemas() throws IOException { + File output = Files.createTempDirectory("forced-gen").toFile().getCanonicalFile(); + output.deleteOnExit(); + + File modelDir = generate(output, CodegenConstants.FORCE_GENERATE_ALL_SCHEMAS); + + for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiShape", "ApiCircle", "ApiSquare")) { + assertTrue(new File(modelDir, name + ".kt").exists(), name + ".kt must be generated with the wildcard"); + } + // With the wildcard, EVERY schema is force-generated — including the otherwise non-mapped + // Container — so all references resolve to stock names and none leaks the mapped FQN. + assertFileContains(Paths.get(modelDir + File.separator + "ApiContainer.kt"), "ApiWidget"); + assertFileNotContains(Paths.get(modelDir + File.separator + "ApiContainer.kt"), "com.example.mapped.Widget"); + } +} diff --git a/modules/openapi-generator/src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml b/modules/openapi-generator/src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml new file mode 100644 index 000000000000..4c04bcb919ee --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml @@ -0,0 +1,74 @@ +openapi: 3.0.0 +info: + title: forcedGenerateSchemas with FQN schemaMappings + description: > + A generic model graph that exercises forcedGenerateSchemas combined with fully-qualified + schemaMappings. Widget/Group/Shape/Circle/Square are mapped to fully-qualified hand-written + classes via schemaMappings but are also force-generated, so the stock ApiXxx models must be + emitted "as if" the mappings did not apply. Container and ShapeBase are neither mapped nor + forced. Shape is a oneOf with a discriminator; Circle/Square inherit ShapeBase via allOf; Label + is a string alias used to verify primitive/type resolution is preserved. + version: 1.0.0 +paths: + /container: + get: + operationId: getContainer + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Container' +components: + schemas: + Container: + type: object + required: [ item ] + properties: + item: + $ref: '#/components/schemas/Widget' + Widget: + type: object + required: [ content ] + properties: + content: + $ref: '#/components/schemas/Group' + Group: + type: object + required: [ shapes ] + properties: + shapes: + type: array + items: + $ref: '#/components/schemas/Shape' + Shape: + oneOf: + - $ref: '#/components/schemas/Circle' + - $ref: '#/components/schemas/Square' + discriminator: + propertyName: type + mapping: + circle: '#/components/schemas/Circle' + square: '#/components/schemas/Square' + ShapeBase: + type: object + required: [ type ] + properties: + type: + type: string + Circle: + type: object + required: [ label ] + allOf: + - $ref: '#/components/schemas/ShapeBase' + - type: object + properties: + label: + $ref: '#/components/schemas/Label' + Square: + type: object + allOf: + - $ref: '#/components/schemas/ShapeBase' + Label: + type: string From 9431eac0c042d02cb14c6e41b29f4169ab000d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1chym=20Metli=C4=8Dka?= Date: Mon, 24 Aug 2026 22:17:19 +0200 Subject: [PATCH 02/11] fix: enhance forced schema generation to bypass schema and import mappings --- .../openapitools/codegen/CodegenConfig.java | 26 +++-- .../openapitools/codegen/DefaultCodegen.java | 61 +--------- .../codegen/DefaultGenerator.java | 105 +++++++++++++++--- .../codegen/config/CodegenConfigurator.java | 4 +- .../languages/AbstractCSharpCodegen.java | 7 ++ .../languages/AbstractJavaCodegen.java | 31 +++--- .../languages/AbstractKotlinCodegen.java | 28 +++-- .../codegen/languages/AbstractPhpCodegen.java | 7 ++ .../languages/AbstractPythonCodegen.java | 7 ++ .../AbstractPythonPydanticV1Codegen.java | 7 ++ .../codegen/languages/GoClientCodegen.java | 7 ++ .../codegen/languages/PerlClientCodegen.java | 7 ++ .../languages/PowerShellClientCodegen.java | 7 ++ .../codegen/languages/RClientCodegen.java | 7 ++ .../codegen/languages/RubyClientCodegen.java | 7 ++ .../ForcedGenerateSchemasCSharpTest.java | 96 ++++++++++++++++ .../kotlin/AbstractKotlinCodegenTest.java | 55 --------- 17 files changed, 299 insertions(+), 170 deletions(-) create mode 100644 modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java index ef16cc409e62..0e68ab773449 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java @@ -169,22 +169,30 @@ default List divideOperationsByContentType(OpenAPI openAPI, String pa * Returns the set of schema names that must be generated even when they appear in * schemaMappings or importMappings (which would normally suppress their generation). *

+ * A force-generated schema is emitted under its stock (unmapped) model name — as if + * neither {@code schemaMapping} nor {@code importMapping} applied to it — while + * {@code typeMapping} is still honored. References from a non-forced model to a + * forced schema keep the mapped (hand-written) class, whereas references between forced schemas + * resolve to their stock names. This is implemented generator-agnostically by a dedicated + * forced-schema generation pass in {@code DefaultGenerator} (no per-generator code required). + *

* Use {@link CodegenConstants#FORCE_GENERATE_ALL_SCHEMAS} ({@code "*"}) as a wildcard * to force-generate all mapped schemas at once. */ Set forcedGenerateSchemas(); /** - * Signals whether the generator is currently building or emitting a model whose schema name - * is in {@link #forcedGenerateSchemas()}. While set, implementations should resolve forced - * schema names to their stock (unmapped) model names so the forced model and its references - * to other forced schemas are generated as if {@code schemaMappings}/{@code importMappings} - * did not apply. {@code typeMapping} and all other resolution are unaffected. - * - * @param generatingForcedModelContext {@code true} while a forced model is being processed. + * Clears any cached mapping from schema name to resolved model name (and related name caches). + *

+ * The generator invokes this between the normal model-generation pass and the forced + * ({@link #forcedGenerateSchemas()}) pass, after the forced schemas' {@code schemaMapping}/ + * {@code importMapping} entries have been temporarily removed, so that forced schema names are + * re-resolved to their stock (unmapped) model names instead of returning stale cached mapped + * names. Implementations that memoize {@link #toModelName(String)} results must override this + * to clear that cache. */ - default void setGeneratingForcedModelContext(boolean generatingForcedModelContext) { - // no-op by default; implemented by DefaultCodegen + default void clearModelNameCache() { + // no-op by default; overridden by generators that memoize model-name resolution } Map inlineSchemaNameMapping(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java index 98f430439090..61499442afc5 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java @@ -187,11 +187,6 @@ public class DefaultCodegen implements CodegenConfig { // a set of schema names that must be generated even when listed in schemaMappings or importMappings. // Use CodegenConstants.FORCE_GENERATE_ALL_SCHEMAS ("*") to force-generate all mapped schemas. protected Set forcedGenerateSchemas = new HashSet<>(); - // transient flag set by DefaultGenerator while it is building/emitting a model whose schema - // name is in forcedGenerateSchemas. When true, name resolution for forced schemas bypasses - // schemaMapping/importMapping so the stock model (and its references to other forced schemas) - // is generated "as if" those mappings did not exist. See toModelName overrides. - protected boolean generatingForcedModelContext = false; // a map to store the mapping between inline schema and the name provided by the user protected Map inlineSchemaNameMapping = new HashMap<>(); // a map to store the inline schema naming conventions @@ -648,12 +643,7 @@ public Map postProcessAllModels(Map objs) List> imports = modelsAttrs.getImports(); for (ModelMap implmo : modelsAttrs.getModels()) { CodegenModel implcm = implmo.getModel(); - // Resolve the implementor's model name the same way the oneOf interface recorded - // its members. A force-generated implementor was named under the forced context - // (stock name), so it must be resolved that way here too; otherwise its stock - // name would not match the FQN mapping and the oneOf interface would never be - // attached to it. - final String modelName = toModelNameInForcedContextIfForced(implcm.name); + String modelName = toModelName(implcm.name); if (additionalDataMap.containsKey(modelName)) { additionalDataMap.get(modelName).addToImplementor(this, implcm, imports, addOneOfInterfaceImports); } @@ -704,7 +694,7 @@ protected Map getModelNameToSchemaCache() { public Map getAllModels(Map objs) { Map allModels = new LinkedHashMap<>(); for (Entry entry : objs.entrySet()) { - String modelName = toModelNameInForcedContextIfForced(entry.getKey()); + String modelName = toModelName(entry.getKey()); List models = entry.getValue().getModels(); for (ModelMap mo : models) { CodegenModel cm = mo.getModel(); @@ -1645,49 +1635,10 @@ public Set forcedGenerateSchemas() { } @Override - public void setGeneratingForcedModelContext(boolean generatingForcedModelContext) { - this.generatingForcedModelContext = generatingForcedModelContext; - } - - /** - * Returns {@code true} when {@code name} is force-generated, i.e. it is listed in - * {@link #forcedGenerateSchemas} or the {@link CodegenConstants#FORCE_GENERATE_ALL_SCHEMAS} - * wildcard is present. Independent of {@link #generatingForcedModelContext}. - */ - protected boolean isForcedSchema(String name) { - return forcedGenerateSchemas.contains(CodegenConstants.FORCE_GENERATE_ALL_SCHEMAS) - || forcedGenerateSchemas.contains(name); - } - - /** - * Returns {@code true} when the named schema should be resolved to its stock (unmapped) model - * name. This is the case while {@link #generatingForcedModelContext} is set (i.e. we are - * building or emitting a forced model) and {@code name} is itself force-generated (either - * listed explicitly or via the {@link CodegenConstants#FORCE_GENERATE_ALL_SCHEMAS} wildcard). - */ - protected boolean isForcedModelNameResolution(String name) { - return generatingForcedModelContext && isForcedSchema(name); - } - - /** - * Resolves {@code schemaName} to its model name, forcing the stock (unmapped) name when the - * schema is force-generated. Force-generated models are named under the forced context (so a - * mapped schema like {@code Content} becomes the stock {@code ApiContent} rather than its FQN - * mapping). Any code that indexes or looks models up by name must use the same resolution, so - * that stock-named references (e.g. {@code oneOf} members) match their model. Non-forced - * schemas resolve exactly as {@link #toModelName(String)} would. - */ - protected String toModelNameInForcedContextIfForced(String schemaName) { - if (!isForcedSchema(schemaName)) { - return toModelName(schemaName); - } - final boolean prev = this.generatingForcedModelContext; - this.generatingForcedModelContext = true; - try { - return toModelName(schemaName); - } finally { - this.generatingForcedModelContext = prev; - } + public void clearModelNameCache() { + // reset the lazily-built model-name -> schema index so it is rebuilt with the current + // schemaMapping/importMapping state (used by the forced-schema generation pass). + modelNameToSchemaCache = null; } @Override diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java index 0181f512bec1..c7bc652c8060 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java @@ -83,6 +83,11 @@ public class DefaultGenerator implements Generator { private Boolean generateModelTests = null; private Boolean generateModelDocumentation = null; private Boolean generateMetadata = true; + // While non-null, the model file-emission loop in generateModels(...) emits only the models + // whose schema name is contained here (all models are still processed for correct parent/ + // interface wiring). Set by generateForcedModels(...) so the forced-schema pass re-emits only + // the forced schemas as their stock (unmapped) models. + private Set restrictModelEmissionTo = null; private String basePath; private String basePathWithoutHost; private String contextPath; @@ -455,6 +460,66 @@ void generateModels(List files, List allModels, List unu generateModels(files, allModels, unusedModels, aliasModels, new ArrayList<>(), DefaultGenerator.this::modelKeys); } + /** + * Forced-schema generation pass (Phase 2). + * + *

Some schemas are excluded from normal model generation because they appear in + * {@code schemaMapping}/{@code importMapping} (they map to a hand-written / external class). + * When such a schema is also listed in {@code forcedGenerateSchemas} (or the {@code "*"} + * wildcard is set), the user wants a stock model generated for it anyway, under its + * unmapped model name.

+ * + *

This is done generator-agnostically: the forced schemas' own {@code schemaMapping}/ + * {@code importMapping} entries are temporarily removed and the model-name caches invalidated, + * so every generator naturally resolves them to their stock names. The full model set is still + * processed (so parents/interfaces of the forced schemas wire up correctly) but only the forced + * schemas are emitted — everything else was already generated in Phase 1 with the mappings + * intact, which is what keeps non-forced references to a forced schema pointing at the mapped + * (FQN) class.

+ */ + void generateForcedModels(List files) { + if (!generateModels || config.forcedGenerateSchemas().isEmpty()) { + return; + } + + // Every forced schema (deferred from Phase 1). Emitted here as stock models. + Set forcedSet = modelKeys().stream() + .filter(name -> !isNotForcedGenerate(name)) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + if (forcedSet.isEmpty()) { + return; + } + + LOGGER.info("Forced-schema generation pass: regenerating stock models for {}", forcedSet); + + // Of the forced schemas, those carrying a schema/import mapping need their entries removed + // so they (and forced->forced references) resolve to stock names during this pass. + Map savedSchemaMappings = new HashMap<>(); + Map savedImportMappings = new HashMap<>(); + for (String name : forcedSet) { + if (config.schemaMapping().containsKey(name)) { + savedSchemaMappings.put(name, config.schemaMapping().remove(name)); + } + if (config.importMapping().containsKey(name)) { + savedImportMappings.put(name, config.importMapping().remove(name)); + } + } + config.clearModelNameCache(); + + try { + restrictModelEmissionTo = forcedSet; + // Throw-away aggregation lists: these models were already accounted for in Phase 1's + // supporting-file bundle; we only want their files (re-)emitted here. + generateModels(files, new ArrayList<>(), ModelUtils.getSchemasUsedOnlyInFormParam(openAPI), new ArrayList<>()); + } finally { + restrictModelEmissionTo = null; + config.schemaMapping().putAll(savedSchemaMappings); + config.importMapping().putAll(savedImportMappings); + config.clearModelNameCache(); + } + } + void generateModels(List files, List allModels, List unusedModels, List aliasModels, List processedModels, Supplier> modelKeysSupplier) { if (!generateModels) { // TODO: Process these anyway and add to dryRun info @@ -477,11 +542,19 @@ void generateModels(List files, List allModels, List unu // process models only for (String name : modelKeys) { processedModels.add(name); - // resolve forced schema names to their stock (unmapped) model names while this model - // is being built, so a forced model and its references to other forced schemas ignore - // schemaMapping/importMapping. Non-forced models keep the mapping. - config.setGeneratingForcedModelContext(!isNotForcedGenerate(name)); try { + // Defer forced schemas to the dedicated forced-schema pass (generateForcedModels), + // which regenerates them with the forced schemas' schemaMapping/importMapping + // entries temporarily removed so every forced model — and every forced->forced + // reference — resolves to its stock (unmapped) name. Non-forced models are still + // generated here with the mappings intact, so a non-forced reference to a forced + // schema keeps the mapped (FQN) class. During the forced pass restrictModelEmissionTo + // is non-null, so this guard is skipped and forced models are processed normally. + if (restrictModelEmissionTo == null && !isNotForcedGenerate(name)) { + LOGGER.info("Model {} deferred to the forced-schema generation pass", name); + continue; + } + //don't generate models that have an import mapping or are in the list of schemas to always generate if (config.schemaMapping().containsKey(name) && isNotForcedGenerate(name)) { LOGGER.info("Model {} not generated due to schema mapping", name); @@ -529,16 +602,9 @@ void generateModels(List files, List allModels, List unu ModelsMap models = processModels(config, schemaMap); models.put("classname", config.toModelName(name)); models.putAll(config.additionalProperties()); - // Reset before inserting into allProcessedModels: that map is a TreeMap ordered by - // config.toModelName(), which is context-sensitive for forced schemas. Inserting - // (and later getting) must use the stable, non-forced naming or the tree ordering - // becomes inconsistent and lookups can miss keys. - config.setGeneratingForcedModelContext(false); allProcessedModels.put(name, models); } catch (Exception e) { throw new RuntimeException("Could not process model '" + name + "'" + ".Please make sure that your schema is correct!", e); - } finally { - config.setGeneratingForcedModelContext(false); } } @@ -548,7 +614,7 @@ void generateModels(List files, List allModels, List unu // post process all processed models allProcessedModels = config.postProcessAllModels(allProcessedModels); - if (generateRecursiveDependentModels) { + if (generateRecursiveDependentModels && restrictModelEmissionTo == null) { for (ModelsMap modelsMap : allProcessedModels.values()) { for (ModelMap mm : modelsMap.getModels()) { CodegenModel cm = mm.getModel(); @@ -570,9 +636,11 @@ void generateModels(List files, List allModels, List unu for (String modelName : allProcessedModels.keySet()) { ModelsMap models = allProcessedModels.get(modelName); models.put("modelPackage", config.modelPackage()); - // keep forced schema names resolving to stock names while the file (and its filename) - // is emitted for a forced model. - config.setGeneratingForcedModelContext(!isNotForcedGenerate(modelName)); + // During the forced-schema pass only the forced schemas are (re-)emitted; every model + // is still processed above so parent/interface wiring is correct. + if (restrictModelEmissionTo != null && !restrictModelEmissionTo.contains(modelName)) { + continue; + } try { //don't generate models that have a schema mapping or are in the list of schemas to always generate if (config.schemaMapping().containsKey(modelName) && isNotForcedGenerate(modelName)) { @@ -620,8 +688,6 @@ void generateModels(List files, List allModels, List unu } catch (Exception e) { throw new RuntimeException("Could not generate model '" + modelName + "'", e); - } finally { - config.setGeneratingForcedModelContext(false); } } if (GlobalSettings.getProperty("debugModels") != null) { @@ -1340,6 +1406,11 @@ public List generate() { Map bundle = buildSupportFileBundle(allOperations, allModels, aliasModels, allWebhooks); generateSupportingFiles(files, bundle); + // forced-schema pass: regenerate stock models for forced schemas that carry a + // schema/import mapping (done last so apis and supporting files above use the intact, + // mapped references) + generateForcedModels(files); + if (dryRun) { boolean verbose = Boolean.parseBoolean(GlobalSettings.getProperty("verbose")); StringBuilder sb = new StringBuilder(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java index ab15c320b2cd..cca5e88096f8 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java @@ -236,7 +236,9 @@ public CodegenConfigurator addSchemaMapping(String key, String value) { /** * Adds a single schema name to {@code forcedGenerateSchemas}. - * Schemas in this set are generated even when they appear in schemaMappings or importMappings. + * Schemas in this set are generated even when they appear in schemaMappings or importMappings, + * emitted under their stock (unmapped) model name. Non-forced references to a forced schema + * keep the mapped class; {@code typeMapping} is preserved. * Use {@code "*"} ({@link CodegenConstants#FORCE_GENERATE_ALL_SCHEMAS}) to force-generate * all mapped schemas at once. */ diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractCSharpCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractCSharpCodegen.java index ab46ba2bb4b2..9ee362956295 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractCSharpCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractCSharpCodegen.java @@ -125,6 +125,13 @@ public abstract class AbstractCSharpCodegen extends DefaultCodegen { // A cache to efficiently lookup schema `toModelName()` based on the schema Key private final Map schemaKeyToModelNameCache = new HashMap<>(); + @Override + public void clearModelNameCache() { + schemaKeyToModelNameCache.clear(); + super.clearModelNameCache(); + } + + // A cache to efficiently lookup CodegenModel `fromModel(codegenModelName, parentModelSchema)` based on the pair of model name and schema private final Map, CodegenModel> codegenModelNameAndSchemaKeyToCodegenModelCache = new HashMap<>(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java index 96ec760d8b24..a8c93c58ef35 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java @@ -232,6 +232,13 @@ protected enum ENUM_PROPERTY_NAMING_TYPE {MACRO_CASE, legacy, original} private Map schemaKeyToModelNameCache = new HashMap<>(); + @Override + public void clearModelNameCache() { + schemaKeyToModelNameCache.clear(); + super.clearModelNameCache(); + } + + public AbstractJavaCodegen() { super(); @@ -1036,21 +1043,15 @@ public String toModelName(final String name) { return modelNameMapping.get(name); } - // When resolving a forced schema (see forcedGenerateSchemas), bypass schemaMapping and the - // name cache so the stock model name is produced "as if" that mapping did not exist. - // typeMapping and every other resolution rule are unaffected. - final boolean forcedResolution = isForcedModelNameResolution(name); - // We need to check if schema-mapping has a different model for this class, so we use it // instead of the auto-generated one. - if (!forcedResolution && schemaMapping.containsKey(name)) { + if (schemaMapping.containsKey(name)) { return schemaMapping.get(name); } - // memoization (skipped for forced resolution so the stock name is never cached under a key - // that non-forced references must still resolve to the mapped name) + // memoization String origName = name; - if (!forcedResolution && schemaKeyToModelNameCache.containsKey(origName)) { + if (schemaKeyToModelNameCache.containsKey(origName)) { return schemaKeyToModelNameCache.get(origName); } @@ -1074,9 +1075,7 @@ public String toModelName(final String name) { // model name cannot use reserved keyword, e.g. return if (isReservedWord(camelizedName)) { final String modelName = "Model" + camelizedName; - if (!forcedResolution) { - schemaKeyToModelNameCache.put(origName, modelName); - } + schemaKeyToModelNameCache.put(origName, modelName); LOGGER.warn("{} (reserved word) cannot be used as model name. Renamed to {}", camelizedName, modelName); return modelName; } @@ -1084,17 +1083,13 @@ public String toModelName(final String name) { // model name starts with number if (camelizedName.matches("^\\d.*")) { final String modelName = "Model" + camelizedName; // e.g. 200Response => Model200Response (after camelize) - if (!forcedResolution) { - schemaKeyToModelNameCache.put(origName, modelName); - } + schemaKeyToModelNameCache.put(origName, modelName); LOGGER.warn("{} (model name starts with number) cannot be used as model name. Renamed to {}", name, modelName); return modelName; } - if (!forcedResolution) { - schemaKeyToModelNameCache.put(origName, camelizedName); - } + schemaKeyToModelNameCache.put(origName, camelizedName); return camelizedName; } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java index 15ed7617813b..14588832766c 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java @@ -124,6 +124,13 @@ public enum KotlinEnumNamingType { protected Set propertyAdditionalKeywords = new HashSet<>(Arrays.asList("entries", "keys", "size", "values")); private final Map schemaKeyToModelNameCache = new HashMap<>(); + + @Override + public void clearModelNameCache() { + schemaKeyToModelNameCache.clear(); + super.clearModelNameCache(); + } + @Getter @Setter protected List additionalModelTypeAnnotations = new LinkedList<>(); @Getter @@ -797,14 +804,8 @@ public String toModelName(final String name) { return modelNameMapping.get(name); } - // When resolving a forced schema (see forcedGenerateSchemas), bypass schemaMapping, - // importMapping and the name cache so the stock model name is produced "as if" those - // mappings did not exist. typeMapping and every other resolution rule are unaffected. - final boolean forcedResolution = isForcedModelNameResolution(name); - - // memoization (skipped for forced resolution so the stock name is never cached under a key - // that non-forced references must still resolve to the mapped name) - if (!forcedResolution && schemaKeyToModelNameCache.containsKey(name)) { + // memoization + if (schemaKeyToModelNameCache.containsKey(name)) { return schemaKeyToModelNameCache.get(name); } @@ -814,13 +815,13 @@ public String toModelName(final String name) { } // If schemaMapping contains name, assume this is a legitimate model name. - if (!forcedResolution && schemaMapping.containsKey(name)) { + if (schemaMapping.containsKey(name)) { return schemaMapping.get(name); } // TODO review importMapping below as we've added schema mapping support // If importMapping contains name, assume this is a legitimate model name. - if (!forcedResolution && importMapping.containsKey(name)) { + if (importMapping.containsKey(name)) { return importMapping.get(name); } @@ -855,11 +856,8 @@ public String toModelName(final String name) { return modelName; } - final String resolvedModelName = titleCase(modifiedName); - if (!forcedResolution) { - schemaKeyToModelNameCache.put(name, resolvedModelName); - } - return resolvedModelName; + schemaKeyToModelNameCache.put(name, titleCase(modifiedName)); + return schemaKeyToModelNameCache.get(name); } /** diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPhpCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPhpCodegen.java index 7051fcea4f87..0c8e2108dc2c 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPhpCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPhpCodegen.java @@ -77,6 +77,13 @@ public abstract class AbstractPhpCodegen extends DefaultCodegen implements Codeg private Map schemaKeyToModelNameCache = new HashMap<>(); + @Override + public void clearModelNameCache() { + schemaKeyToModelNameCache.clear(); + super.clearModelNameCache(); + } + + public AbstractPhpCodegen() { super(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java index f2ab264952cd..e0e819615042 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java @@ -74,6 +74,13 @@ public abstract class AbstractPythonCodegen extends DefaultCodegen implements Co protected Map regexModifiers; private Map schemaKeyToModelNameCache = new HashMap<>(); + + @Override + public void clearModelNameCache() { + schemaKeyToModelNameCache.clear(); + super.clearModelNameCache(); + } + // map of set (model imports) private HashMap> circularImports = new HashMap<>(); // map of codegen models diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonPydanticV1Codegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonPydanticV1Codegen.java index 796e99e1eab4..b56fcdf9f5f0 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonPydanticV1Codegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonPydanticV1Codegen.java @@ -60,6 +60,13 @@ public abstract class AbstractPythonPydanticV1Codegen extends DefaultCodegen imp protected Map regexModifiers; private Map schemaKeyToModelNameCache = new HashMap<>(); + + @Override + public void clearModelNameCache() { + schemaKeyToModelNameCache.clear(); + super.clearModelNameCache(); + } + // map of set (model imports) private HashMap> circularImports = new HashMap<>(); // map of codegen models diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GoClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GoClientCodegen.java index d9625e9f5656..ed2706864f99 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GoClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GoClientCodegen.java @@ -73,6 +73,13 @@ public class GoClientCodegen extends AbstractGoCodegen { // A cache to efficiently lookup schema `toModelName()` based on the schema Key private Map schemaKeyToModelNameCache = new HashMap<>(); + @Override + public void clearModelNameCache() { + schemaKeyToModelNameCache.clear(); + super.clearModelNameCache(); + } + + public GoClientCodegen() { super(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PerlClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PerlClientCodegen.java index 1a2edd611db7..69e4e5169695 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PerlClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PerlClientCodegen.java @@ -54,6 +54,13 @@ public class PerlClientCodegen extends DefaultCodegen implements CodegenConfig { private Map schemaKeyToModelNameCache = new HashMap<>(); + @Override + public void clearModelNameCache() { + schemaKeyToModelNameCache.clear(); + super.clearModelNameCache(); + } + + public PerlClientCodegen() { super(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PowerShellClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PowerShellClientCodegen.java index 54dcc31db675..b842af9ec8ff 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PowerShellClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PowerShellClientCodegen.java @@ -77,6 +77,13 @@ public class PowerShellClientCodegen extends DefaultCodegen implements CodegenCo private Map schemaKeyToModelNameCache = new HashMap<>(); + @Override + public void clearModelNameCache() { + schemaKeyToModelNameCache.clear(); + super.clearModelNameCache(); + } + + /** * Constructs an instance of `PowerShellClientCodegen`. */ diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RClientCodegen.java index 980baf7a6a65..72ac2b4a5027 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RClientCodegen.java @@ -85,6 +85,13 @@ public class RClientCodegen extends DefaultCodegen implements CodegenConfig { private Map schemaKeyToModelNameCache = new HashMap<>(); + @Override + public void clearModelNameCache() { + schemaKeyToModelNameCache.clear(); + super.clearModelNameCache(); + } + + @Override public CodegenType getTag() { return CodegenType.CLIENT; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RubyClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RubyClientCodegen.java index 6a3481fad6f4..780fa96dbfdd 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RubyClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RubyClientCodegen.java @@ -82,6 +82,13 @@ public class RubyClientCodegen extends AbstractRubyCodegen { private Map schemaKeyToModelNameCache = new HashMap<>(); + @Override + public void clearModelNameCache() { + schemaKeyToModelNameCache.clear(); + super.clearModelNameCache(); + } + + public RubyClientCodegen() { super(); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java new file mode 100644 index 000000000000..e72b5b21d845 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java @@ -0,0 +1,96 @@ +package org.openapitools.codegen.csharpnetcore; + +import org.openapitools.codegen.ClientOptInput; +import org.openapitools.codegen.CodegenConstants; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.TestUtils; +import org.openapitools.codegen.languages.CSharpClientCodegen; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Arrays; + +import static org.openapitools.codegen.TestUtils.assertFileContains; +import static org.openapitools.codegen.TestUtils.assertFileNotContains; +import static org.testng.Assert.assertTrue; + +/** + * End-to-end coverage for {@code forcedGenerateSchemas} combined with fully-qualified + * {@code schemaMappings} on the C# generator. + *

+ * This mirrors {@code ForcedGenerateSchemasKotlinTest} on a different generator family to + * demonstrate that the two-phase forced-schema generation in {@link DefaultGenerator} is + * generator-agnostic — it works with no per-generator code. The same generic model graph is used + * (Widget/Group/Shape/Circle/Square are mapped to hand-written classes but also force-generated). + */ +public class ForcedGenerateSchemasCSharpTest { + + private static final String SPEC = "src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml"; + private static final String MODEL_DIR = "/src/Org.OpenAPITools/Model/"; + + private File generate(File output, String... forcedSchemas) { + final CSharpClientCodegen codegen = new CSharpClientCodegen(); + codegen.setLibrary("restsharp"); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.setModelNamePrefix("Api"); + + codegen.schemaMapping().put("Widget", "Com.Example.Mapped.Widget"); + codegen.schemaMapping().put("Group", "Com.Example.Mapped.Group"); + codegen.schemaMapping().put("Shape", "Com.Example.Mapped.Shape"); + codegen.schemaMapping().put("Circle", "Com.Example.Mapped.Circle"); + codegen.schemaMapping().put("Square", "Com.Example.Mapped.Square"); + + codegen.forcedGenerateSchemas().addAll(Arrays.asList(forcedSchemas)); + + DefaultGenerator generator = new DefaultGenerator(); + generator.setGenerateMetadata(false); + generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true"); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_TESTS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_DOCS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false"); + + generator.opts(new ClientOptInput() + .openAPI(TestUtils.parseSpec(SPEC)) + .config(codegen)) + .generate(); + + return new File(output, MODEL_DIR); + } + + @Test + public void forcedFqnMappedSchemasAreGeneratedWithStockNames() throws IOException { + File output = Files.createTempDirectory("forced-gen-csharp").toFile().getCanonicalFile(); + output.deleteOnExit(); + + File modelDir = generate(output, "Widget", "Group", "Shape", "Circle", "Square"); + + // The forced+mapped schemas are emitted as stock ApiXxx classes despite the FQN mapping. + for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiCircle", "ApiSquare")) { + assertTrue(new File(modelDir, name + ".cs").exists(), name + ".cs must be generated"); + } + assertFileContains(Paths.get(modelDir + File.separator + "ApiWidget.cs"), "class ApiWidget"); + + // Container is neither mapped nor forced: its reference to Widget keeps the mapped class. + assertFileContains(Paths.get(modelDir + File.separator + "ApiContainer.cs"), "Com.Example.Mapped.Widget"); + } + + @Test + public void wildcardForcesAllMappedSchemas() throws IOException { + File output = Files.createTempDirectory("forced-gen-csharp").toFile().getCanonicalFile(); + output.deleteOnExit(); + + File modelDir = generate(output, CodegenConstants.FORCE_GENERATE_ALL_SCHEMAS); + + for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiCircle", "ApiSquare", "ApiContainer")) { + assertTrue(new File(modelDir, name + ".cs").exists(), name + ".cs must be generated with the wildcard"); + } + // With the wildcard EVERY schema is force-generated, so Container references the stock class + // and no mapped FQN leaks. + assertFileContains(Paths.get(modelDir + File.separator + "ApiContainer.cs"), "ApiWidget"); + assertFileNotContains(Paths.get(modelDir + File.separator + "ApiContainer.cs"), "Com.Example.Mapped.Widget"); + } +} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/AbstractKotlinCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/AbstractKotlinCodegenTest.java index 1bfc95138f34..a41847474210 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/AbstractKotlinCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/AbstractKotlinCodegenTest.java @@ -34,61 +34,6 @@ public class AbstractKotlinCodegenTest { private AbstractKotlinCodegen codegen; - @Test - public void forcedGenerateSchemaBypassesSchemaMappingForDeclarationAndCache() { - codegen.setModelNamePrefix("Api"); - codegen.schemaMapping().put("Widget", "com.example.mapped.Widget"); - codegen.forcedGenerateSchemas().add("Widget"); - - // Without the forced context (i.e. a reference from a non-forced model), the mapped FQN is - // returned so production references keep resolving to the hand-written class. - assertEquals(codegen.toModelName("Widget"), "com.example.mapped.Widget"); - - // While generating the forced model, the stock (prefixed) name is produced instead. - codegen.setGeneratingForcedModelContext(true); - assertEquals(codegen.toModelName("Widget"), "ApiWidget"); - - // Turning the context back off restores the mapped name: the forced resolution must not - // poison the name cache under the "Widget" key. - codegen.setGeneratingForcedModelContext(false); - assertEquals(codegen.toModelName("Widget"), "com.example.mapped.Widget"); - } - - @Test - public void forcedGenerateSchemaWildcardBypassesSchemaMapping() { - codegen.setModelNamePrefix("Api"); - codegen.schemaMapping().put("Widget", "com.example.mapped.Widget"); - codegen.forcedGenerateSchemas().add(CodegenConstants.FORCE_GENERATE_ALL_SCHEMAS); - - codegen.setGeneratingForcedModelContext(true); - assertEquals(codegen.toModelName("Widget"), "ApiWidget"); - } - - @Test - public void forcedGenerateContextOnlyAffectsForcedSchemas() { - codegen.setModelNamePrefix("Api"); - codegen.schemaMapping().put("Widget", "com.example.mapped.Widget"); - codegen.schemaMapping().put("Other", "com.example.other.Other"); - codegen.forcedGenerateSchemas().add("Widget"); - - codegen.setGeneratingForcedModelContext(true); - // Widget is forced -> stock name. - assertEquals(codegen.toModelName("Widget"), "ApiWidget"); - // Other is mapped but NOT forced -> mapping still honored even inside a forced context. - assertEquals(codegen.toModelName("Other"), "com.example.other.Other"); - } - - @Test - public void forcedGeneratePreservesImportMappingBypass() { - codegen.setModelNamePrefix("Api"); - codegen.importMapping().put("Widget", "com.example.mapped.Widget"); - codegen.forcedGenerateSchemas().add("Widget"); - - assertEquals(codegen.toModelName("Widget"), "com.example.mapped.Widget"); - codegen.setGeneratingForcedModelContext(true); - assertEquals(codegen.toModelName("Widget"), "ApiWidget"); - } - /** * using @BeforeMethod to have a fresh codegen mock for each test */ From 3baff9fbf7f43d6d45a3f969e53669237c1138d7 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Mon, 24 Aug 2026 22:45:06 +0200 Subject: [PATCH 03/11] fix: update forced schema generation to emit mapping-suppressed schemas as isolated shadow models --- .../openapitools/codegen/cmd/Generate.java | 6 +-- .../codegen/config/GeneratorSettings.java | 16 +++--- .../codegen/plugin/CodeGenMojo.java | 4 +- .../openapitools/codegen/CodegenConfig.java | 14 ++--- .../codegen/CodegenConstants.java | 2 +- .../openapitools/codegen/DefaultCodegen.java | 10 ++-- .../codegen/DefaultGenerator.java | 46 ++++++++++------- .../codegen/config/CodegenConfigurator.java | 10 ++-- .../codegen/DefaultGeneratorTest.java | 30 +++++++++++ .../ForcedGenerateSchemasCSharpTest.java | 8 +-- .../ForcedGenerateSchemasSpringTest.java | 51 ++++++++++++++++--- .../ForcedGenerateSchemasKotlinTest.java | 11 ++-- .../forced-generate-schema-mapping.yaml | 10 ++++ 13 files changed, 152 insertions(+), 66 deletions(-) diff --git a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java index d0707f90388b..b7fde281cce8 100644 --- a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java +++ b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java @@ -204,9 +204,9 @@ public class Generate extends OpenApiGeneratorCommand { @Option( name = {"--forced-generate-schemas"}, title = "forced generate schemas", - description = "comma-separated list of schema names that must be generated even when listed " - + "in schemaMappings or importMappings. Example: MyEnum,OtherSchema." - + " Use the wildcard '*' to force-generate all mapped schemas at once." + description = "comma-separated list of mapping-suppressed schemas to emit as isolated shadow models." + + " Example: MyEnum,OtherSchema." + + " Use the wildcard '*' to include all mapping-suppressed schemas." + " You can also have multiple occurrences of this option.") private List forcedGenerateSchemas = new ArrayList<>(); diff --git a/modules/openapi-generator-core/src/main/java/org/openapitools/codegen/config/GeneratorSettings.java b/modules/openapi-generator-core/src/main/java/org/openapitools/codegen/config/GeneratorSettings.java index 9f18c8b85afb..3b5e85b2127f 100644 --- a/modules/openapi-generator-core/src/main/java/org/openapitools/codegen/config/GeneratorSettings.java +++ b/modules/openapi-generator-core/src/main/java/org/openapitools/codegen/config/GeneratorSettings.java @@ -256,8 +256,8 @@ public Map getSchemaMappings() { } /** - * Gets the set of schema names that must be generated even when listed in schemaMappings or importMappings. - * Use {@code "*"} as a wildcard to force-generate all mapped schemas at once. + * Gets the mapping-suppressed schemas to emit as isolated shadow models. + * Use {@code "*"} as a wildcard to include all mapping-suppressed schemas. * * @return the forced generate schemas */ @@ -974,8 +974,8 @@ public Builder withSchemaMapping(String key, String value) { } /** - * Sets the {@code forcedGenerateSchemas} (schemas to generate even when listed in schemaMappings or importMappings). - * Use {@code "*"} as a wildcard to force-generate all mapped schemas at once. + * Sets the mapping-suppressed schemas to emit as isolated shadow models. + * Use {@code "*"} as a wildcard to include all mapping-suppressed schemas. * and returns a reference to this Builder so that the methods can be chained together. * * @param schemas the {@code forcedGenerateSchemas} to set @@ -987,8 +987,8 @@ public Builder withForcedGenerateSchemas(Set schemas) { } /** - * Adds a single schema name to {@code forcedGenerateSchemas} (schemas to generate even when listed in schemaMappings or importMappings). - * Use {@code "*"} as a wildcard to force-generate all mapped schemas at once. + * Adds a mapping-suppressed schema to emit as an isolated shadow model. + * Use {@code "*"} as a wildcard to include all mapping-suppressed schemas. * Returns a reference to this Builder so that the methods can be chained together. * * @param schema the schema name to add @@ -1211,9 +1211,9 @@ public Builder withOperationIdNameMapping(String key, String value) { } /** - * Sets the {@code injectModelExtensions} and returns a reference to this Builder so that the methods can be chained together. + * Sets the {@code injectModelVendorExtensions} and returns a reference to this Builder so that the methods can be chained together. * - * @param injectModelExtensions the {@code injectModelExtensions} to set + * @param injectModelVendorExtensions the {@code injectModelVendorExtensions} to set * @return a reference to this Builder */ public Builder withInjectModelVendorExtensions(Map injectModelVendorExtensions) { diff --git a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java index 084fab18b5c4..48084971a948 100644 --- a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java +++ b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java @@ -398,8 +398,8 @@ public class CodeGenMojo extends AbstractMojo { private List schemaMappings; /** - * A list of schema names that must be generated even when listed in schemaMappings or importMappings. - * Use {@code *} as a wildcard to force-generate all mapped schemas at once. + * Mapping-suppressed schemas to emit as isolated shadow models. + * Use {@code *} as a wildcard to include all mapping-suppressed schemas. */ @Parameter(name = "forcedGenerateSchemas", property = "openapi.generator.maven.plugin.forcedGenerateSchemas") private List forcedGenerateSchemas; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java index 0e68ab773449..d7623b8a6188 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java @@ -169,15 +169,15 @@ default List divideOperationsByContentType(OpenAPI openAPI, String pa * Returns the set of schema names that must be generated even when they appear in * schemaMappings or importMappings (which would normally suppress their generation). *

- * A force-generated schema is emitted under its stock (unmapped) model name — as if - * neither {@code schemaMapping} nor {@code importMapping} applied to it — while - * {@code typeMapping} is still honored. References from a non-forced model to a - * forced schema keep the mapped (hand-written) class, whereas references between forced schemas - * resolve to their stock names. This is implemented generator-agnostically by a dedicated - * forced-schema generation pass in {@code DefaultGenerator} (no per-generator code required). + * A force-generated schema is emitted as an isolated shadow model under its stock + * (unmapped) model name — as if neither {@code schemaMapping} nor {@code importMapping} applied + * to it — while {@code typeMapping} is still honored. Shadow models may reference one another + * by their stock names, but they are not added to generated APIs or the normal model metadata + * used by supporting files. Ordinary generated code continues to use the mapped classes. *

* Use {@link CodegenConstants#FORCE_GENERATE_ALL_SCHEMAS} ({@code "*"}) as a wildcard - * to force-generate all mapped schemas at once. + * to force-generate all schemas that would otherwise be suppressed by mappings. Schemas without + * a suppressing mapping remain in the normal generation pass. */ Set forcedGenerateSchemas(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java index 03faf2e32189..71a24f3af009 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java @@ -38,7 +38,7 @@ public class CodegenConstants { /** * Wildcard token for {@code forcedGenerateSchemas}: when this value is present in the set, - * all schemas are generated even if they appear in schemaMappings or importMappings. + * all schemas suppressed by schemaMappings or importMappings are emitted as shadow models. */ public static final String FORCE_GENERATE_ALL_SCHEMAS = "*"; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java index 61499442afc5..47e8d9d386f4 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java @@ -59,11 +59,7 @@ import org.openapitools.codegen.meta.GeneratorMetadata; import org.openapitools.codegen.meta.Stability; import org.openapitools.codegen.meta.features.*; -import org.openapitools.codegen.model.ModelMap; -import org.openapitools.codegen.model.ModelsMap; -import org.openapitools.codegen.model.OperationsMap; -import org.openapitools.codegen.model.WebhooksMap; -import org.openapitools.codegen.model.EnumVarMap; +import org.openapitools.codegen.model.*; import org.openapitools.codegen.serializer.SerializerUtils; import org.openapitools.codegen.templating.MustacheEngineAdapter; import org.openapitools.codegen.templating.mustache.*; @@ -184,8 +180,8 @@ public class DefaultCodegen implements CodegenConfig { protected Map importMapping = new HashMap<>(); // a map to store the mapping between a schema and the new one protected Map schemaMapping = new HashMap<>(); - // a set of schema names that must be generated even when listed in schemaMappings or importMappings. - // Use CodegenConstants.FORCE_GENERATE_ALL_SCHEMAS ("*") to force-generate all mapped schemas. + // Mapping-suppressed schemas to emit as isolated shadow models. + // Use CodegenConstants.FORCE_GENERATE_ALL_SCHEMAS ("*") to include all suppressed schemas. protected Set forcedGenerateSchemas = new HashSet<>(); // a map to store the mapping between inline schema and the name provided by the user protected Map inlineSchemaNameMapping = new HashMap<>(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java index c7bc652c8060..39060e66eac0 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java @@ -409,15 +409,21 @@ private void generateModelTests(List files, Map models, St } } - /** - * Returns {@code true} if the named schema should be generated even when it appears in - * schemaMappings or importMappings. This is the case when the schema name is explicitly - * listed in {@code forcedGenerateSchemas} or when the wildcard - * {@link CodegenConstants#FORCE_GENERATE_ALL_SCHEMAS} ({@code "*"}) is present. - */ - private boolean isNotForcedGenerate(String schemaName) { - return !config.forcedGenerateSchemas().contains(CodegenConstants.FORCE_GENERATE_ALL_SCHEMAS) - && !config.forcedGenerateSchemas().contains(schemaName); + private boolean isForcedSchemaRequested(String schemaName) { + return config.forcedGenerateSchemas().contains(CodegenConstants.FORCE_GENERATE_ALL_SCHEMAS) + || config.forcedGenerateSchemas().contains(schemaName); + } + + private boolean isModelSuppressedByMapping(String schemaName) { + if (config.schemaMapping().containsKey(schemaName)) { + return true; + } + String mappedTypeName = config.typeMapping().get(schemaName); + return mappedTypeName != null && config.importMapping().containsKey(mappedTypeName); + } + + private boolean isForcedShadowSchema(String schemaName) { + return isForcedSchemaRequested(schemaName) && isModelSuppressedByMapping(schemaName); } private void generateModelDocumentation(List files, Map models, String modelName) throws IOException { @@ -482,9 +488,9 @@ void generateForcedModels(List files) { return; } - // Every forced schema (deferred from Phase 1). Emitted here as stock models. + // Requested schemas that mappings suppressed in Phase 1. Emitted here as stock models. Set forcedSet = modelKeys().stream() - .filter(name -> !isNotForcedGenerate(name)) + .filter(this::isForcedShadowSchema) .collect(Collectors.toCollection(LinkedHashSet::new)); if (forcedSet.isEmpty()) { @@ -504,13 +510,17 @@ void generateForcedModels(List files) { if (config.importMapping().containsKey(name)) { savedImportMappings.put(name, config.importMapping().remove(name)); } + String mappedTypeName = config.typeMapping().get(name); + if (mappedTypeName != null && config.importMapping().containsKey(mappedTypeName)) { + savedImportMappings.put(mappedTypeName, config.importMapping().remove(mappedTypeName)); + } } config.clearModelNameCache(); try { restrictModelEmissionTo = forcedSet; - // Throw-away aggregation lists: these models were already accounted for in Phase 1's - // supporting-file bundle; we only want their files (re-)emitted here. + // Throw-away aggregation lists: shadow models are intentionally excluded from APIs and + // supporting-file metadata; this pass only emits their model artifacts. generateModels(files, new ArrayList<>(), ModelUtils.getSchemasUsedOnlyInFormParam(openAPI), new ArrayList<>()); } finally { restrictModelEmissionTo = null; @@ -550,13 +560,13 @@ void generateModels(List files, List allModels, List unu // generated here with the mappings intact, so a non-forced reference to a forced // schema keeps the mapped (FQN) class. During the forced pass restrictModelEmissionTo // is non-null, so this guard is skipped and forced models are processed normally. - if (restrictModelEmissionTo == null && !isNotForcedGenerate(name)) { + if (restrictModelEmissionTo == null && isForcedShadowSchema(name)) { LOGGER.info("Model {} deferred to the forced-schema generation pass", name); continue; } - //don't generate models that have an import mapping or are in the list of schemas to always generate - if (config.schemaMapping().containsKey(name) && isNotForcedGenerate(name)) { + // don't generate models that have a schema mapping + if (config.schemaMapping().containsKey(name)) { LOGGER.info("Model {} not generated due to schema mapping", name); continue; } @@ -642,8 +652,8 @@ void generateModels(List files, List allModels, List unu continue; } try { - //don't generate models that have a schema mapping or are in the list of schemas to always generate - if (config.schemaMapping().containsKey(modelName) && isNotForcedGenerate(modelName)) { + // don't generate models that have a schema mapping + if (config.schemaMapping().containsKey(modelName)) { continue; } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java index cca5e88096f8..de42edb2211e 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java @@ -236,11 +236,11 @@ public CodegenConfigurator addSchemaMapping(String key, String value) { /** * Adds a single schema name to {@code forcedGenerateSchemas}. - * Schemas in this set are generated even when they appear in schemaMappings or importMappings, - * emitted under their stock (unmapped) model name. Non-forced references to a forced schema - * keep the mapped class; {@code typeMapping} is preserved. + * Mapping-suppressed schemas in this set are also emitted as isolated shadow models under their + * stock (unmapped) model names. APIs, ordinary models, and supporting-file metadata continue to + * use the mapped classes; {@code typeMapping} is preserved. * Use {@code "*"} ({@link CodegenConstants#FORCE_GENERATE_ALL_SCHEMAS}) to force-generate - * all mapped schemas at once. + * all mapping-suppressed schemas at once. Unmapped schemas remain in normal generation. */ public CodegenConfigurator addForcedGenerateSchema(String schema) { this.forcedGenerateSchemas.add(schema); @@ -251,7 +251,7 @@ public CodegenConfigurator addForcedGenerateSchema(String schema) { /** * Replaces the entire {@code forcedGenerateSchemas} set. * Use {@code "*"} ({@link CodegenConstants#FORCE_GENERATE_ALL_SCHEMAS}) as a wildcard - * to force-generate all mapped schemas at once. + * to force-generate all mapping-suppressed schemas at once. */ public CodegenConfigurator setForcedGenerateSchemas(Set schemas) { this.forcedGenerateSchemas = schemas; diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java index 44b9e0d57396..72f83d5c48bc 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java @@ -523,6 +523,36 @@ public void forcedGenerateSchemaOverridesSchemaMappingSkip() throws IOException } finally { target3.toFile().deleteOnExit(); } + + // --- Part 4: typeMapping + importMapping suppression must also be bypassed --- + Path target4 = Files.createTempDirectory("test-forced-gen-type-import"); + try { + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("java") + .setInputSpec("src/test/resources/3_0/petstore.yaml") + .setOutputDir(target4.toAbsolutePath().toString()) + .addTypeMapping("Category", "ExternalCategory") + .addImportMapping("ExternalCategory", "com.example.ExternalCategory") + .addForcedGenerateSchema("Category"); + + DefaultGenerator generator = new DefaultGenerator(false); + generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true"); + generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_TESTS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_DOCS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false"); + + List files = generator.opts(configurator.toClientOptInput()).generate(); + + Assert.assertTrue( + files.stream().anyMatch(f -> f.getPath().replace('\\', '/').endsWith(originalModelRelPath)), + "Category.java MUST be generated when forced generation overrides type/import mapping suppression"); + Assert.assertTrue( + new File(target4.toFile(), originalModelRelPath).exists(), + "Category.java MUST exist when forced generation overrides type/import mapping suppression"); + } finally { + target4.toFile().deleteOnExit(); + } } @Test diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java index e72b5b21d845..3e0004e2d05a 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java @@ -88,9 +88,9 @@ public void wildcardForcesAllMappedSchemas() throws IOException { for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiCircle", "ApiSquare", "ApiContainer")) { assertTrue(new File(modelDir, name + ".cs").exists(), name + ".cs must be generated with the wildcard"); } - // With the wildcard EVERY schema is force-generated, so Container references the stock class - // and no mapped FQN leaks. - assertFileContains(Paths.get(modelDir + File.separator + "ApiContainer.cs"), "ApiWidget"); - assertFileNotContains(Paths.get(modelDir + File.separator + "ApiContainer.cs"), "Com.Example.Mapped.Widget"); + // The wildcard selects only mapping-suppressed schemas. Container remains a normal model, + // so its reference continues to use the mapped production class. + assertFileContains(Paths.get(modelDir + File.separator + "ApiContainer.cs"), "Com.Example.Mapped.Widget"); + assertFileNotContains(Paths.get(modelDir + File.separator + "ApiContainer.cs"), "ApiWidget"); } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java index cf6bf5ffe9b5..52dfe38f3371 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java @@ -5,6 +5,7 @@ import org.openapitools.codegen.DefaultGenerator; import org.openapitools.codegen.TestUtils; import org.openapitools.codegen.languages.SpringCodegen; +import org.openapitools.codegen.model.ModelMap; import org.testng.annotations.Test; import java.io.File; @@ -12,7 +13,10 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; +import java.util.Map; import static org.openapitools.codegen.TestUtils.assertFileContains; import static org.openapitools.codegen.TestUtils.assertFileNotContains; @@ -38,7 +42,10 @@ public class ForcedGenerateSchemasSpringTest { private static final String MODEL_DIR = "/src/main/java/org/openapitools/model/"; private File generate(File output, String... forcedSchemas) { - final SpringCodegen codegen = new SpringCodegen(); + return generate(output, new SpringCodegen(), false, forcedSchemas); + } + + private File generate(File output, SpringCodegen codegen, boolean generateApis, String... forcedSchemas) { codegen.setOutputDir(output.getAbsolutePath()); codegen.setModelNamePrefix("Api"); codegen.setUseOneOfInterfaces(true); @@ -55,7 +62,7 @@ private File generate(File output, String... forcedSchemas) { DefaultGenerator generator = new DefaultGenerator(); generator.setGenerateMetadata(false); generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true"); - generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, Boolean.toString(generateApis)); generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_TESTS, "false"); generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_DOCS, "false"); generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false"); @@ -69,6 +76,18 @@ private File generate(File output, String... forcedSchemas) { return new File(output, MODEL_DIR); } + private static class CapturingSpringCodegen extends SpringCodegen { + private List supportingModels; + + @Override + public Map postProcessSupportingFileData(Map objs) { + supportingModels = ((List) objs.get("models")).stream() + .map(ModelMap.class::cast) + .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); + return super.postProcessSupportingFileData(objs); + } + } + @Test public void forcedFqnMappedSchemasAreGeneratedWithValidStockNames() throws IOException { File output = Files.createTempDirectory("forced-gen-spring").toFile().getCanonicalFile(); @@ -141,6 +160,26 @@ public void nonForcedModelStillHonorsTheMapping() throws IOException { assertFileContains(Paths.get(modelDir + File.separator + "ApiContainer.java"), "com.example.mapped.Widget"); } + @Test + public void forcedSchemasDoNotLeakIntoApisOrSupportingModelMetadata() throws IOException { + File output = Files.createTempDirectory("forced-gen-spring").toFile().getCanonicalFile(); + output.deleteOnExit(); + CapturingSpringCodegen codegen = new CapturingSpringCodegen(); + + generate(output, codegen, true, "Widget", "Group", "Shape", "Circle", "Square", "Container"); + + Path api = Paths.get(output + "/src/main/java/org/openapitools/api/WidgetApi.java"); + assertFileContains(api, "com.example.mapped.Widget"); + assertFileNotContains(api, "ApiWidget"); + + List supportingModelNames = codegen.supportingModels.stream() + .map(ModelMap::getModel) + .map(model -> model.classname) + .toList(); + assertTrue(supportingModelNames.contains("ApiContainer")); + assertFalse(supportingModelNames.contains("ApiWidget")); + } + @Test public void wildcardForcesAllMappedSchemas() throws IOException { File output = Files.createTempDirectory("forced-gen-spring").toFile().getCanonicalFile(); @@ -151,9 +190,9 @@ public void wildcardForcesAllMappedSchemas() throws IOException { for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiShape", "ApiCircle", "ApiSquare")) { assertTrue(new File(modelDir, name + ".java").exists(), name + ".java must be generated with the wildcard"); } - // With the wildcard, EVERY schema is force-generated — including the otherwise non-mapped - // Container — so all references resolve to stock names and none leaks the mapped FQN. - assertFileContains(Paths.get(modelDir + File.separator + "ApiContainer.java"), "ApiWidget"); - assertFileNotContains(Paths.get(modelDir + File.separator + "ApiContainer.java"), "com.example.mapped.Widget"); + // The wildcard selects only mapping-suppressed schemas. Container remains a normal model, + // so its reference continues to use the mapped production class. + assertFileContains(Paths.get(modelDir + File.separator + "ApiContainer.java"), "com.example.mapped.Widget"); + assertFileNotContains(Paths.get(modelDir + File.separator + "ApiContainer.java"), "ApiWidget"); } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/ForcedGenerateSchemasKotlinTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/ForcedGenerateSchemasKotlinTest.java index 167aefe02ac7..03b1df81b3fd 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/ForcedGenerateSchemasKotlinTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/ForcedGenerateSchemasKotlinTest.java @@ -44,7 +44,8 @@ public class ForcedGenerateSchemasKotlinTest { private File generate(File output, String... forcedSchemas) { final KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen(); - codegen.setOutputDir(output.getAbsolutePath()); codegen.setModelNamePrefix("Api"); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.setModelNamePrefix("Api"); codegen.setUseOneOfInterfaces(true); codegen.setLegacyDiscriminatorBehavior(false); @@ -156,9 +157,9 @@ public void wildcardForcesAllMappedSchemas() throws IOException { for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiShape", "ApiCircle", "ApiSquare")) { assertTrue(new File(modelDir, name + ".kt").exists(), name + ".kt must be generated with the wildcard"); } - // With the wildcard, EVERY schema is force-generated — including the otherwise non-mapped - // Container — so all references resolve to stock names and none leaks the mapped FQN. - assertFileContains(Paths.get(modelDir + File.separator + "ApiContainer.kt"), "ApiWidget"); - assertFileNotContains(Paths.get(modelDir + File.separator + "ApiContainer.kt"), "com.example.mapped.Widget"); + // The wildcard selects only mapping-suppressed schemas. Container remains a normal model, + // so its reference continues to use the mapped production class. + assertFileContains(Paths.get(modelDir + File.separator + "ApiContainer.kt"), "com.example.mapped.Widget"); + assertFileNotContains(Paths.get(modelDir + File.separator + "ApiContainer.kt"), "ApiWidget"); } } diff --git a/modules/openapi-generator/src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml b/modules/openapi-generator/src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml index 4c04bcb919ee..45c204362af5 100644 --- a/modules/openapi-generator/src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml @@ -10,6 +10,16 @@ info: is a string alias used to verify primitive/type resolution is preserved. version: 1.0.0 paths: + /widget: + get: + operationId: getWidget + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' /container: get: operationId: getContainer From 674094ecfe9eeea1a13bb7d492b1d4fac99f1923 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Mon, 24 Aug 2026 22:53:45 +0200 Subject: [PATCH 04/11] fix: improve documentation and comments in schema handling classes --- modules/openapi-generator-cli/pom.xml | 2 ++ .../org/openapitools/codegen/OpenAPINormalizer.java | 3 +-- .../codegen/languages/PagedModelScanUtils.java | 13 +++---------- .../codegen/languages/SpringPageableScanUtils.java | 4 ++-- .../org/openapitools/codegen/utils/ModelUtils.java | 2 +- 5 files changed, 9 insertions(+), 15 deletions(-) diff --git a/modules/openapi-generator-cli/pom.xml b/modules/openapi-generator-cli/pom.xml index c7f93432d3e6..c00f67b0a3d6 100644 --- a/modules/openapi-generator-cli/pom.xml +++ b/modules/openapi-generator-cli/pom.xml @@ -70,6 +70,8 @@ META-INF/*.SF META-INF/*.DSA META-INF/*.RSA + module-info.class + META-INF/versions/**/module-info.class diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java index a684bf326683..a6689d3535de 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java @@ -2735,8 +2735,7 @@ private boolean hasType(String type) { * When set to true, remove "properties" attribute on schema other than "object" * since it should be ignored and may result in odd generated code * - * @param schema Schema - * @return Schema + * @param schema Schema */ protected void processNormalizeOtherThanObjectWithProperties(Schema schema) { if (getRule(REMOVE_PROPERTIES_FROM_TYPE_OTHER_THAN_OBJECT)) { diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java index f948e4da1d3e..2939c0841a16 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java @@ -78,24 +78,17 @@ private PagedModelScanUtils() {} * names are identical. When {@link #scanPagedModels(OpenAPI, UnaryOperator)} is used, they * may differ (e.g. {@code rawSchemaName="UserPage"}, {@code schemaName="UserPageDto"}).

* - * @param schemaName Transformed model name of the detected paged schema. - * @param itemSchemaName Raw item schema name (always raw; callers apply - * {@code toModelName()} at the point of use). - * @param metaSchemaName Transformed model name of the pagination-metadata schema, - * or {@code null} if unresolved. - * @param rawSchemaName Raw OpenAPI schema name of the paged schema (for {@code objs.remove}). - * @param rawMetaSchemaName Raw OpenAPI schema name of the pagination-metadata schema - * (for {@code objs.remove}), or {@code null} if unresolved. */ public static final class DetectedPagedModel { /** Transformed model name — use for import removal / import-presence checks. */ public final String schemaName; + /** Raw item schema name; callers apply {@code toModelName()} at the point of use. */ public final String itemSchemaName; - /** Transformed meta model name — use for import-presence checks. */ + /** Transformed meta model name for import checks, or {@code null} if unresolved. */ public final String metaSchemaName; /** Raw OpenAPI schema name — use for {@code objs.remove()} in {@code postProcessAllModels}. */ public final String rawSchemaName; - /** Raw OpenAPI meta schema name — use for {@code objs.remove()} in {@code postProcessAllModels}. */ + /** Raw OpenAPI meta schema name for {@code objs.remove()}, or {@code null} if unresolved. */ public final String rawMetaSchemaName; /** diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java index f64d7efba594..d51880306894 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java @@ -50,7 +50,7 @@ public class SpringPageableScanUtils { /** * The three Spring Data Web query-parameter names that together signal a - * {@link org.springframework.data.domain.Pageable} operation: + * {@code Pageable} operation: * {@code page}, {@code size}, and {@code sort}. * *

Use this constant instead of repeating {@code Arrays.asList("page", "size", "sort")} @@ -274,7 +274,7 @@ public static boolean applyAutoXSpringPaginatedIfNeeded( * {@code size}, {@code sort}) from the given codegen operation's parameter lists. * *

When an operation is marked with {@code x-spring-paginated}, Spring injects a single - * {@link org.springframework.data.domain.Pageable} parameter that internally handles + * {@code Pageable} parameter that internally handles * {@code page}, {@code size}, and {@code sort}. The individual query parameters must * therefore be removed from the generated method signature.

* 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 fe612c09dcd9..7a1417054c2d 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 @@ -2512,7 +2512,7 @@ public static String getType(Schema schema) { * For 3.1 spec, set as types, for 3.0, type * * @param schema the schema - * @return schema type + * @param type the schema type */ public static void setType(Schema schema, String type) { if (schema instanceof JsonSchema) { From d656d8d07044156ac225f6aafb2a90a2630899f4 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Mon, 24 Aug 2026 23:01:09 +0200 Subject: [PATCH 05/11] fix: implement schema mapping for Category and update Pet model references --- bin/configs/kotlin-spring-boot-3.yaml | 4 ++ bin/configs/spring-boot-3.yaml | 4 ++ .../java/com/example/mapped/Category.java | 38 +++++++++++++++++++ .../main/java/org/openapitools/model/Pet.java | 13 +++---- .../ForcedGenerateSchemasTest.java | 22 +++++++++++ .../kotlin/com/example/mapped/Category.kt | 14 +++++++ .../main/kotlin/org/openapitools/model/Pet.kt | 3 +- 7 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 samples/openapi3/server/petstore/springboot-3/src/main/java/com/example/mapped/Category.java create mode 100644 samples/openapi3/server/petstore/springboot-3/src/test/java/org/openapitools/ForcedGenerateSchemasTest.java create mode 100644 samples/server/petstore/kotlin-springboot-3/src/main/kotlin/com/example/mapped/Category.kt diff --git a/bin/configs/kotlin-spring-boot-3.yaml b/bin/configs/kotlin-spring-boot-3.yaml index a102ecb2e695..ce7c6521da4a 100644 --- a/bin/configs/kotlin-spring-boot-3.yaml +++ b/bin/configs/kotlin-spring-boot-3.yaml @@ -3,6 +3,10 @@ outputDir: samples/server/petstore/kotlin-springboot-3 library: spring-boot inputSpec: modules/openapi-generator/src/test/resources/3_0/petstore.yaml templateDir: modules/openapi-generator/src/main/resources/kotlin-spring +schemaMappings: + Category: com.example.mapped.Category +forcedGenerateSchemas: + - Category additionalProperties: generateJsonIncludeAnnotations: "true" generateJsonSetterNullsAnnotations: "true" diff --git a/bin/configs/spring-boot-3.yaml b/bin/configs/spring-boot-3.yaml index 37b8d2738f7d..9336688a63e0 100644 --- a/bin/configs/spring-boot-3.yaml +++ b/bin/configs/spring-boot-3.yaml @@ -2,6 +2,10 @@ generatorName: spring outputDir: samples/openapi3/server/petstore/springboot-3 inputSpec: modules/openapi-generator/src/test/resources/3_0/petstore.yaml templateDir: modules/openapi-generator/src/main/resources/JavaSpring +schemaMappings: + Category: com.example.mapped.Category +forcedGenerateSchemas: + - Category additionalProperties: generateJsonIncludeAnnotations: "true" generateJsonSetterNullsAnnotations: "true" diff --git a/samples/openapi3/server/petstore/springboot-3/src/main/java/com/example/mapped/Category.java b/samples/openapi3/server/petstore/springboot-3/src/main/java/com/example/mapped/Category.java new file mode 100644 index 000000000000..4fa657f481e3 --- /dev/null +++ b/samples/openapi3/server/petstore/springboot-3/src/main/java/com/example/mapped/Category.java @@ -0,0 +1,38 @@ +package com.example.mapped; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.lang.Nullable; + +/** + * Handwritten production model used through the Category schema mapping. + */ +public class Category { + private @Nullable Long id; + private @Nullable String name; + + public Category() { + } + + public Category(@Nullable Long id, @Nullable String name) { + this.id = id; + this.name = name; + } + + @JsonProperty("id") + public @Nullable Long getId() { + return id; + } + + public void setId(@Nullable Long id) { + this.id = id; + } + + @JsonProperty("name") + public @Nullable String getName() { + return name; + } + + public void setName(@Nullable String name) { + this.name = name; + } +} diff --git a/samples/openapi3/server/petstore/springboot-3/src/main/java/org/openapitools/model/Pet.java b/samples/openapi3/server/petstore/springboot-3/src/main/java/org/openapitools/model/Pet.java index 078989824e39..c79bfa986347 100644 --- a/samples/openapi3/server/petstore/springboot-3/src/main/java/org/openapitools/model/Pet.java +++ b/samples/openapi3/server/petstore/springboot-3/src/main/java/org/openapitools/model/Pet.java @@ -9,7 +9,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import org.openapitools.model.Category; import org.openapitools.model.Tag; import org.springframework.lang.Nullable; import org.openapitools.jackson.nullable.JsonNullable; @@ -41,7 +40,7 @@ public class Pet { private @Nullable Long id; @JsonInclude(JsonInclude.Include.NON_NULL) - private @Nullable Category category; + private @Nullable com.example.mapped.Category category; @JsonInclude(JsonInclude.Include.NON_NULL) private String name; @@ -110,7 +109,7 @@ public Pet(String name, List photoUrls) { /** * Constructor with all args parameters */ - public Pet(@Nullable Long id, @Nullable Category category, String name, List photoUrls, List<@Valid Tag> tags, @Nullable StatusEnum status) { + public Pet(@Nullable Long id, @Nullable com.example.mapped.Category category, String name, List photoUrls, List<@Valid Tag> tags, @Nullable StatusEnum status) { this.id = id; this.category = category; this.name = name; @@ -143,7 +142,7 @@ public void setId(@Nullable Long id) { this.id = id; } - public Pet category(@Nullable Category category) { + public Pet category(@Nullable com.example.mapped.Category category) { this.category = category; return this; } @@ -157,13 +156,13 @@ public Pet category(@Nullable Category category) { @JsonProperty("category") @JacksonXmlProperty(localName = "Category") @XmlElement(name = "Category") - public @Nullable Category getCategory() { + public @Nullable com.example.mapped.Category getCategory() { return category; } @JsonProperty("category") @JacksonXmlProperty(localName = "Category") - public void setCategory(@Nullable Category category) { + public void setCategory(@Nullable com.example.mapped.Category category) { this.category = category; } @@ -362,7 +361,7 @@ public Pet.Builder id(Long id) { return this; } - public Pet.Builder category(Category category) { + public Pet.Builder category(com.example.mapped.Category category) { this.instance.category(category); return this; } diff --git a/samples/openapi3/server/petstore/springboot-3/src/test/java/org/openapitools/ForcedGenerateSchemasTest.java b/samples/openapi3/server/petstore/springboot-3/src/test/java/org/openapitools/ForcedGenerateSchemasTest.java new file mode 100644 index 000000000000..be45f1e97e61 --- /dev/null +++ b/samples/openapi3/server/petstore/springboot-3/src/test/java/org/openapitools/ForcedGenerateSchemasTest.java @@ -0,0 +1,22 @@ +package org.openapitools; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.openapitools.model.Pet; + +import static org.assertj.core.api.Assertions.assertThat; + +class ForcedGenerateSchemasTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void mappedAndGeneratedShadowCategoriesHaveEquivalentJson() throws Exception { + com.example.mapped.Category mapped = new com.example.mapped.Category(1L, "dogs"); + org.openapitools.model.Category shadow = new org.openapitools.model.Category(1L, "dogs"); + + assertThat(new Pet().category(mapped).getCategory()).isSameAs(mapped); + assertThat(objectMapper.readTree(objectMapper.writeValueAsString(mapped))) + .isEqualTo(objectMapper.readTree(objectMapper.writeValueAsString(shadow))); + } +} diff --git a/samples/server/petstore/kotlin-springboot-3/src/main/kotlin/com/example/mapped/Category.kt b/samples/server/petstore/kotlin-springboot-3/src/main/kotlin/com/example/mapped/Category.kt new file mode 100644 index 000000000000..b719bdfce27a --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-3/src/main/kotlin/com/example/mapped/Category.kt @@ -0,0 +1,14 @@ +package com.example.mapped + +import com.fasterxml.jackson.annotation.JsonProperty +import java.io.Serializable + +/** + * Handwritten production model used through the Category schema mapping. + */ +data class Category( + @get:JsonProperty("id") + val id: Long? = null, + @get:JsonProperty("name") + val name: String? = null +) : Serializable diff --git a/samples/server/petstore/kotlin-springboot-3/src/main/kotlin/org/openapitools/model/Pet.kt b/samples/server/petstore/kotlin-springboot-3/src/main/kotlin/org/openapitools/model/Pet.kt index e031de49227a..68297f6c8308 100644 --- a/samples/server/petstore/kotlin-springboot-3/src/main/kotlin/org/openapitools/model/Pet.kt +++ b/samples/server/petstore/kotlin-springboot-3/src/main/kotlin/org/openapitools/model/Pet.kt @@ -7,7 +7,6 @@ import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonSetter import com.fasterxml.jackson.annotation.JsonValue import com.fasterxml.jackson.annotation.Nulls -import org.openapitools.model.Category import org.openapitools.model.Tag import jakarta.validation.constraints.DecimalMax import jakarta.validation.constraints.DecimalMin @@ -45,7 +44,7 @@ data class Pet( @field:Valid @field:JsonSetter(nulls = Nulls.SKIP) @param:JsonProperty("category") - @get:JsonProperty("category") val category: Category? = null, + @get:JsonProperty("category") val category: com.example.mapped.Category? = null, @field:Valid @field:JsonSetter(nulls = Nulls.SKIP) From 0a194d3d8e38fdbb55907f8305f61c988d0bad5d Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Mon, 24 Aug 2026 23:09:21 +0200 Subject: [PATCH 06/11] fix: enhance forced schema generation to emit mapping-suppressed schemas as isolated shadow models --- .../OpenApiGeneratorGenerateExtension.kt | 5 ++++- .../gradle/plugin/tasks/GenerateTask.kt | 5 ++++- .../openapitools/codegen/DefaultGenerator.java | 16 +++++++--------- .../kotlin/forced-generate-schema-mapping.yaml | 9 +++++---- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt index c2b2e9a23a17..8d80648a76ed 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt @@ -246,7 +246,10 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) { val schemaMappings = project.objects.mapProperty() /** - * Specifies schema names that must be generated even when listed in schemaMappings or importMappings + * Specifies mapping-suppressed schemas to emit as isolated shadow models. + * + * Use `"*"` to include all mapping-suppressed schemas. Unmapped schemas remain in normal + * generation, and generated APIs and supporting-file metadata continue to use mapped classes. */ val forcedGenerateSchemas = project.objects.listProperty() diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt index 913ab08c96ed..33c9067d6d2b 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt @@ -641,7 +641,10 @@ abstract class GenerateTask : DefaultTask() { abstract val schemaMappings: MapProperty /** - * Specifies schema names that must be generated even when listed in schemaMappings or importMappings. + * Specifies mapping-suppressed schemas to emit as isolated shadow models. + * + * Use `"*"` to include all mapping-suppressed schemas. Unmapped schemas remain in normal + * generation, and generated APIs and supporting-file metadata continue to use mapped classes. */ @get:Optional @get:Input diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java index 39060e66eac0..360906ac551c 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java @@ -469,19 +469,17 @@ void generateModels(List files, List allModels, List unu /** * Forced-schema generation pass (Phase 2). * - *

Some schemas are excluded from normal model generation because they appear in - * {@code schemaMapping}/{@code importMapping} (they map to a hand-written / external class). - * When such a schema is also listed in {@code forcedGenerateSchemas} (or the {@code "*"} - * wildcard is set), the user wants a stock model generated for it anyway, under its - * unmapped model name.

+ *

Some schemas are excluded from normal model generation because a {@code schemaMapping}, + * or a {@code typeMapping} backed by an {@code importMapping}, replaces them with a hand-written + * external class. When such a schema is selected by {@code forcedGenerateSchemas}, this pass + * emits an isolated shadow model under its stock, unmapped name.

* *

This is done generator-agnostically: the forced schemas' own {@code schemaMapping}/ * {@code importMapping} entries are temporarily removed and the model-name caches invalidated, * so every generator naturally resolves them to their stock names. The full model set is still - * processed (so parents/interfaces of the forced schemas wire up correctly) but only the forced - * schemas are emitted — everything else was already generated in Phase 1 with the mappings - * intact, which is what keeps non-forced references to a forced schema pointing at the mapped - * (FQN) class.

+ * processed (so parents/interfaces of the forced schemas wire up correctly) but only shadow + * schemas are emitted. APIs, ordinary models, and supporting-file metadata were generated in + * Phase 1 with mappings intact, so they continue to reference the mapped FQN classes.

*/ void generateForcedModels(List files) { if (!generateModels || config.forcedGenerateSchemas().isEmpty()) { diff --git a/modules/openapi-generator/src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml b/modules/openapi-generator/src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml index 45c204362af5..0b0eaaef220f 100644 --- a/modules/openapi-generator/src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml @@ -4,10 +4,11 @@ info: description: > A generic model graph that exercises forcedGenerateSchemas combined with fully-qualified schemaMappings. Widget/Group/Shape/Circle/Square are mapped to fully-qualified hand-written - classes via schemaMappings but are also force-generated, so the stock ApiXxx models must be - emitted "as if" the mappings did not apply. Container and ShapeBase are neither mapped nor - forced. Shape is a oneOf with a discriminator; Circle/Square inherit ShapeBase via allOf; Label - is a string alias used to verify primitive/type resolution is preserved. + classes via schemaMappings but are also emitted as isolated shadow models, so the stock ApiXxx + models must be generated "as if" the mappings did not apply. Container and ShapeBase are neither + mapped nor shadow models and continue to use mapped production references. Shape is a oneOf with + a discriminator; Circle/Square inherit ShapeBase via allOf; Label is a string alias used to + verify primitive/type resolution is preserved. version: 1.0.0 paths: /widget: From 6337f8ac4a993c67e062980db8b552a01efd8034 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 25 Aug 2026 08:44:56 +0200 Subject: [PATCH 07/11] fix: implement ForcedSchemaSupport interface for generator families to handle isolated shadow model generation --- modules/openapi-generator-cli/pom.xml | 2 - .../openapitools/codegen/cmd/Generate.java | 2 + .../codegen/config/GeneratorSettings.java | 10 +- .../OpenApiGeneratorGenerateExtension.kt | 2 + .../gradle/plugin/tasks/GenerateTask.kt | 2 + .../codegen/plugin/CodeGenMojo.java | 2 + .../openapitools/codegen/CodegenConfig.java | 22 +-- .../codegen/CodegenConstants.java | 3 +- .../openapitools/codegen/DefaultCodegen.java | 7 +- .../codegen/DefaultGenerator.java | 131 ++++++++++-------- .../codegen/ForcedSchemaSupport.java | 13 ++ .../codegen/OpenAPINormalizer.java | 3 +- .../codegen/config/CodegenConfigurator.java | 3 + .../languages/AbstractCSharpCodegen.java | 3 +- .../languages/AbstractJavaCodegen.java | 3 +- .../languages/AbstractKotlinCodegen.java | 2 +- .../codegen/languages/AbstractPhpCodegen.java | 3 +- .../languages/AbstractPythonCodegen.java | 2 +- .../AbstractPythonPydanticV1Codegen.java | 2 +- .../codegen/languages/GoClientCodegen.java | 3 +- .../languages/PagedModelScanUtils.java | 13 +- .../codegen/languages/PerlClientCodegen.java | 3 +- .../languages/PowerShellClientCodegen.java | 3 +- .../codegen/languages/RClientCodegen.java | 3 +- .../codegen/languages/RubyClientCodegen.java | 3 +- .../languages/SpringPageableScanUtils.java | 4 +- .../codegen/utils/ModelUtils.java | 2 +- .../codegen/DefaultGeneratorTest.java | 14 ++ ...dGenerateSchemasSupportedFamiliesTest.java | 91 ++++++++++++ .../ForcedGenerateSchemasCSharpTest.java | 2 +- .../ForcedGenerateSchemasSpringTest.java | 24 ++++ .../kotlin/AbstractKotlinCodegenTest.java | 1 + 32 files changed, 272 insertions(+), 111 deletions(-) create mode 100644 modules/openapi-generator/src/main/java/org/openapitools/codegen/ForcedSchemaSupport.java create mode 100644 modules/openapi-generator/src/test/java/org/openapitools/codegen/ForcedGenerateSchemasSupportedFamiliesTest.java diff --git a/modules/openapi-generator-cli/pom.xml b/modules/openapi-generator-cli/pom.xml index c00f67b0a3d6..c7f93432d3e6 100644 --- a/modules/openapi-generator-cli/pom.xml +++ b/modules/openapi-generator-cli/pom.xml @@ -70,8 +70,6 @@ META-INF/*.SF META-INF/*.DSA META-INF/*.RSA - module-info.class - META-INF/versions/**/module-info.class diff --git a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java index b7fde281cce8..ab5d153d0cbf 100644 --- a/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java +++ b/modules/openapi-generator-cli/src/main/java/org/openapitools/codegen/cmd/Generate.java @@ -207,6 +207,8 @@ public class Generate extends OpenApiGeneratorCommand { description = "comma-separated list of mapping-suppressed schemas to emit as isolated shadow models." + " Example: MyEnum,OtherSchema." + " Use the wildcard '*' to include all mapping-suppressed schemas." + + " Supported families: Java, Groovy, Kotlin, C#, Python, Python Pydantic v1, PHP," + + " Go client, Perl, PowerShell, R, and Ruby; others fail before writing files." + " You can also have multiple occurrences of this option.") private List forcedGenerateSchemas = new ArrayList<>(); diff --git a/modules/openapi-generator-core/src/main/java/org/openapitools/codegen/config/GeneratorSettings.java b/modules/openapi-generator-core/src/main/java/org/openapitools/codegen/config/GeneratorSettings.java index 3b5e85b2127f..d41fc0ef6bf5 100644 --- a/modules/openapi-generator-core/src/main/java/org/openapitools/codegen/config/GeneratorSettings.java +++ b/modules/openapi-generator-core/src/main/java/org/openapitools/codegen/config/GeneratorSettings.java @@ -258,6 +258,8 @@ public Map getSchemaMappings() { /** * Gets the mapping-suppressed schemas to emit as isolated shadow models. * Use {@code "*"} as a wildcard to include all mapping-suppressed schemas. + * Supported families are Java, Groovy, Kotlin, C#, Python, Python Pydantic v1, PHP, Go client, Perl, + * PowerShell, R, and Ruby; other generators fail before writing files. * * @return the forced generate schemas */ @@ -976,7 +978,8 @@ public Builder withSchemaMapping(String key, String value) { /** * Sets the mapping-suppressed schemas to emit as isolated shadow models. * Use {@code "*"} as a wildcard to include all mapping-suppressed schemas. - * and returns a reference to this Builder so that the methods can be chained together. + * Unsupported generator families fail before writing files. + * Returns a reference to this Builder so that the methods can be chained together. * * @param schemas the {@code forcedGenerateSchemas} to set * @return a reference to this Builder @@ -989,6 +992,7 @@ public Builder withForcedGenerateSchemas(Set schemas) { /** * Adds a mapping-suppressed schema to emit as an isolated shadow model. * Use {@code "*"} as a wildcard to include all mapping-suppressed schemas. + * Unsupported generator families fail before writing files. * Returns a reference to this Builder so that the methods can be chained together. * * @param schema the schema name to add @@ -1211,9 +1215,9 @@ public Builder withOperationIdNameMapping(String key, String value) { } /** - * Sets the {@code injectModelVendorExtensions} and returns a reference to this Builder so that the methods can be chained together. + * Sets the {@code injectModelExtensions} and returns a reference to this Builder so that the methods can be chained together. * - * @param injectModelVendorExtensions the {@code injectModelVendorExtensions} to set + * @param injectModelExtensions the {@code injectModelExtensions} to set * @return a reference to this Builder */ public Builder withInjectModelVendorExtensions(Map injectModelVendorExtensions) { diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt index 8d80648a76ed..1938dc82625b 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/extensions/OpenApiGeneratorGenerateExtension.kt @@ -250,6 +250,8 @@ open class OpenApiGeneratorGenerateExtension(private val project: Project) { * * Use `"*"` to include all mapping-suppressed schemas. Unmapped schemas remain in normal * generation, and generated APIs and supporting-file metadata continue to use mapped classes. + * Supported families are Java, Groovy, Kotlin, C#, Python, Python Pydantic v1, PHP, Go client, Perl, + * PowerShell, R, and Ruby; other generators fail before writing files. */ val forcedGenerateSchemas = project.objects.listProperty() diff --git a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt index 33c9067d6d2b..e8332d672a25 100644 --- a/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt +++ b/modules/openapi-generator-gradle-plugin/src/main/kotlin/org/openapitools/generator/gradle/plugin/tasks/GenerateTask.kt @@ -645,6 +645,8 @@ abstract class GenerateTask : DefaultTask() { * * Use `"*"` to include all mapping-suppressed schemas. Unmapped schemas remain in normal * generation, and generated APIs and supporting-file metadata continue to use mapped classes. + * Supported families are Java, Groovy, Kotlin, C#, Python, Python Pydantic v1, PHP, Go client, Perl, + * PowerShell, R, and Ruby; other generators fail before writing files. */ @get:Optional @get:Input diff --git a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java index 48084971a948..bb17cb9cbfb7 100644 --- a/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java +++ b/modules/openapi-generator-maven-plugin/src/main/java/org/openapitools/codegen/plugin/CodeGenMojo.java @@ -400,6 +400,8 @@ public class CodeGenMojo extends AbstractMojo { /** * Mapping-suppressed schemas to emit as isolated shadow models. * Use {@code *} as a wildcard to include all mapping-suppressed schemas. + * Supported families are Java, Groovy, Kotlin, C#, Python, Python Pydantic v1, PHP, Go client, Perl, + * PowerShell, R, and Ruby; other generators fail before writing files. */ @Parameter(name = "forcedGenerateSchemas", property = "openapi.generator.maven.plugin.forcedGenerateSchemas") private List forcedGenerateSchemas; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java index d7623b8a6188..bc6224314044 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConfig.java @@ -166,8 +166,8 @@ default List divideOperationsByContentType(OpenAPI openAPI, String pa Map schemaMapping(); /** - * Returns the set of schema names that must be generated even when they appear in - * schemaMappings or importMappings (which would normally suppress their generation). + * Returns the set of schema names that must be generated even when suppressed by + * {@code schemaMapping}, or by a {@code typeMapping} with a matching {@code importMapping}. *

* A force-generated schema is emitted as an isolated shadow model under its stock * (unmapped) model name — as if neither {@code schemaMapping} nor {@code importMapping} applied @@ -178,22 +178,12 @@ default List divideOperationsByContentType(OpenAPI openAPI, String pa * Use {@link CodegenConstants#FORCE_GENERATE_ALL_SCHEMAS} ({@code "*"}) as a wildcard * to force-generate all schemas that would otherwise be suppressed by mappings. Schemas without * a suppressing mapping remain in the normal generation pass. - */ - Set forcedGenerateSchemas(); - - /** - * Clears any cached mapping from schema name to resolved model name (and related name caches). *

- * The generator invokes this between the normal model-generation pass and the forced - * ({@link #forcedGenerateSchemas()}) pass, after the forced schemas' {@code schemaMapping}/ - * {@code importMapping} entries have been temporarily removed, so that forced schema names are - * re-resolved to their stock (unmapped) model names instead of returning stale cached mapped - * names. Implementations that memoize {@link #toModelName(String)} results must override this - * to clear that cache. + * Supported generator families are Java, Groovy, Kotlin, C#, Python, Python Pydantic v1, PHP, Go + * client, Perl, PowerShell, R, and Ruby. Other generators reject this option before generating + * files. */ - default void clearModelNameCache() { - // no-op by default; overridden by generators that memoize model-name resolution - } + Set forcedGenerateSchemas(); Map inlineSchemaNameMapping(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java index 71a24f3af009..3997dcb362ae 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java @@ -38,7 +38,8 @@ public class CodegenConstants { /** * Wildcard token for {@code forcedGenerateSchemas}: when this value is present in the set, - * all schemas suppressed by schemaMappings or importMappings are emitted as shadow models. + * all schemas suppressed by schema mappings or type-plus-import mappings are emitted as shadow + * models. */ public static final String FORCE_GENERATE_ALL_SCHEMAS = "*"; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java index 47e8d9d386f4..82ef6cafbc5c 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java @@ -59,7 +59,11 @@ import org.openapitools.codegen.meta.GeneratorMetadata; import org.openapitools.codegen.meta.Stability; import org.openapitools.codegen.meta.features.*; -import org.openapitools.codegen.model.*; +import org.openapitools.codegen.model.ModelMap; +import org.openapitools.codegen.model.ModelsMap; +import org.openapitools.codegen.model.OperationsMap; +import org.openapitools.codegen.model.WebhooksMap; +import org.openapitools.codegen.model.EnumVarMap; import org.openapitools.codegen.serializer.SerializerUtils; import org.openapitools.codegen.templating.MustacheEngineAdapter; import org.openapitools.codegen.templating.mustache.*; @@ -1630,7 +1634,6 @@ public Set forcedGenerateSchemas() { return forcedGenerateSchemas; } - @Override public void clearModelNameCache() { // reset the lazily-built model-name -> schema index so it is rebuilt with the current // schemaMapping/importMapping state (used by the forced-schema generation pass). diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java index 360906ac551c..e03d26b558b4 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java @@ -83,11 +83,6 @@ public class DefaultGenerator implements Generator { private Boolean generateModelTests = null; private Boolean generateModelDocumentation = null; private Boolean generateMetadata = true; - // While non-null, the model file-emission loop in generateModels(...) emits only the models - // whose schema name is contained here (all models are still processed for correct parent/ - // interface wiring). Set by generateForcedModels(...) so the forced-schema pass re-emits only - // the forced schemas as their stock (unmapped) models. - private Set restrictModelEmissionTo = null; private String basePath; private String basePathWithoutHost; private String contextPath; @@ -414,16 +409,28 @@ private boolean isForcedSchemaRequested(String schemaName) { || config.forcedGenerateSchemas().contains(schemaName); } - private boolean isModelSuppressedByMapping(String schemaName) { - if (config.schemaMapping().containsKey(schemaName)) { - return true; - } + private boolean isSuppressedByTypeAndImportMapping(String schemaName) { String mappedTypeName = config.typeMapping().get(schemaName); return mappedTypeName != null && config.importMapping().containsKey(mappedTypeName); } + private boolean isSuppressedByMapping(String schemaName) { + return config.schemaMapping().containsKey(schemaName) + || isSuppressedByTypeAndImportMapping(schemaName); + } + private boolean isForcedShadowSchema(String schemaName) { - return isForcedSchemaRequested(schemaName) && isModelSuppressedByMapping(schemaName); + return isForcedSchemaRequested(schemaName) && isSuppressedByMapping(schemaName); + } + + private ForcedSchemaSupport forcedSchemaSupport() { + if (config instanceof ForcedSchemaSupport) { + return (ForcedSchemaSupport) config; + } + throw new IllegalArgumentException(String.format(Locale.ROOT, + "Generator '%s' does not support forcedGenerateSchemas. " + + "Use a supported generator family or remove forcedGenerateSchemas.", + config.getName())); } private void generateModelDocumentation(List files, Map models, String modelName) throws IOException { @@ -463,23 +470,21 @@ private void generateModel(List files, Map models, String } void generateModels(List files, List allModels, List unusedModels, List aliasModels) { - generateModels(files, allModels, unusedModels, aliasModels, new ArrayList<>(), DefaultGenerator.this::modelKeys); + generateModels(files, allModels, unusedModels, aliasModels, new ArrayList<>(), DefaultGenerator.this::modelKeys, + false, Collections.emptySet()); } /** * Forced-schema generation pass (Phase 2). * - *

Some schemas are excluded from normal model generation because a {@code schemaMapping}, - * or a {@code typeMapping} backed by an {@code importMapping}, replaces them with a hand-written - * external class. When such a schema is selected by {@code forcedGenerateSchemas}, this pass - * emits an isolated shadow model under its stock, unmapped name.

- * - *

This is done generator-agnostically: the forced schemas' own {@code schemaMapping}/ - * {@code importMapping} entries are temporarily removed and the model-name caches invalidated, - * so every generator naturally resolves them to their stock names. The full model set is still - * processed (so parents/interfaces of the forced schemas wire up correctly) but only shadow - * schemas are emitted. APIs, ordinary models, and supporting-file metadata were generated in - * Phase 1 with mappings intact, so they continue to reference the mapped FQN classes.

+ *

A schema suppressed from normal generation by a {@code schemaMapping} (or a + * {@code typeMapping} backed by an {@code importMapping}) but selected via + * {@code forcedGenerateSchemas} is emitted here as an isolated shadow model under its stock, + * unmapped name. The suppressing mappings are temporarily removed and the mapping-sensitive + * model-name caches invalidated so the schemas resolve to stock names; the full model set is + * still processed (so parents/interfaces wire up) but only the forced schemas are emitted. + * APIs, ordinary models, and supporting-file metadata were produced in Phase 1 with mappings + * intact and keep referencing the mapped classes.

*/ void generateForcedModels(List files) { if (!generateModels || config.forcedGenerateSchemas().isEmpty()) { @@ -497,38 +502,47 @@ void generateForcedModels(List files) { LOGGER.info("Forced-schema generation pass: regenerating stock models for {}", forcedSet); - // Of the forced schemas, those carrying a schema/import mapping need their entries removed - // so they (and forced->forced references) resolve to stock names during this pass. - Map savedSchemaMappings = new HashMap<>(); - Map savedImportMappings = new HashMap<>(); - for (String name : forcedSet) { - if (config.schemaMapping().containsKey(name)) { - savedSchemaMappings.put(name, config.schemaMapping().remove(name)); - } - if (config.importMapping().containsKey(name)) { - savedImportMappings.put(name, config.importMapping().remove(name)); - } - String mappedTypeName = config.typeMapping().get(name); - if (mappedTypeName != null && config.importMapping().containsKey(mappedTypeName)) { - savedImportMappings.put(mappedTypeName, config.importMapping().remove(mappedTypeName)); - } - } - config.clearModelNameCache(); + Map savedSchemaMappings = new LinkedHashMap<>(config.schemaMapping()); + Map savedImportMappings = new LinkedHashMap<>(config.importMapping()); + Map savedAdditionalProperties = new LinkedHashMap<>(config.additionalProperties()); + ForcedSchemaSupport support = forcedSchemaSupport(); try { - restrictModelEmissionTo = forcedSet; + for (String schemaName : forcedSet) { + config.schemaMapping().remove(schemaName); + config.importMapping().remove(schemaName); + String mappedTypeName = config.typeMapping().get(schemaName); + if (mappedTypeName != null) { + config.importMapping().remove(mappedTypeName); + } + } + support.clearModelNameCache(); + // Throw-away aggregation lists: shadow models are intentionally excluded from APIs and // supporting-file metadata; this pass only emits their model artifacts. - generateModels(files, new ArrayList<>(), ModelUtils.getSchemasUsedOnlyInFormParam(openAPI), new ArrayList<>()); + generateModels(files, new ArrayList<>(), ModelUtils.getSchemasUsedOnlyInFormParam(openAPI), + new ArrayList<>(), new ArrayList<>(), DefaultGenerator.this::modelKeys, + true, forcedSet); } finally { - restrictModelEmissionTo = null; + config.schemaMapping().clear(); config.schemaMapping().putAll(savedSchemaMappings); + config.importMapping().clear(); config.importMapping().putAll(savedImportMappings); - config.clearModelNameCache(); + config.additionalProperties().clear(); + config.additionalProperties().putAll(savedAdditionalProperties); + support.clearModelNameCache(); } } void generateModels(List files, List allModels, List unusedModels, List aliasModels, List processedModels, Supplier> modelKeysSupplier) { + generateModels(files, allModels, unusedModels, aliasModels, processedModels, modelKeysSupplier, + false, Collections.emptySet()); + } + + private void generateModels(List files, List allModels, List unusedModels, + List aliasModels, List processedModels, + Supplier> modelKeysSupplier, boolean shadowPass, + Set modelsToEmit) { if (!generateModels) { // TODO: Process these anyway and add to dryRun info LOGGER.info("Skipping generation of models."); @@ -551,19 +565,14 @@ void generateModels(List files, List allModels, List unu for (String name : modelKeys) { processedModels.add(name); try { - // Defer forced schemas to the dedicated forced-schema pass (generateForcedModels), - // which regenerates them with the forced schemas' schemaMapping/importMapping - // entries temporarily removed so every forced model — and every forced->forced - // reference — resolves to its stock (unmapped) name. Non-forced models are still - // generated here with the mappings intact, so a non-forced reference to a forced - // schema keeps the mapped (FQN) class. During the forced pass restrictModelEmissionTo - // is non-null, so this guard is skipped and forced models are processed normally. - if (restrictModelEmissionTo == null && isForcedShadowSchema(name)) { + // Defer forced+mapping-suppressed schemas to generateForcedModels(), which re-emits + // them under their stock (unmapped) names. Non-forced models are generated here with + // mappings intact. During the shadow pass this guard is skipped. + if (!shadowPass && isForcedShadowSchema(name)) { LOGGER.info("Model {} deferred to the forced-schema generation pass", name); continue; } - // don't generate models that have a schema mapping if (config.schemaMapping().containsKey(name)) { LOGGER.info("Model {} not generated due to schema mapping", name); continue; @@ -622,7 +631,7 @@ void generateModels(List files, List allModels, List unu // post process all processed models allProcessedModels = config.postProcessAllModels(allProcessedModels); - if (generateRecursiveDependentModels && restrictModelEmissionTo == null) { + if (generateRecursiveDependentModels && !shadowPass) { for (ModelsMap modelsMap : allProcessedModels.values()) { for (ModelMap mm : modelsMap.getModels()) { CodegenModel cm = mm.getModel(); @@ -646,7 +655,7 @@ void generateModels(List files, List allModels, List unu models.put("modelPackage", config.modelPackage()); // During the forced-schema pass only the forced schemas are (re-)emitted; every model // is still processed above so parent/interface wiring is correct. - if (restrictModelEmissionTo != null && !restrictModelEmissionTo.contains(modelName)) { + if (shadowPass && !modelsToEmit.contains(modelName)) { continue; } try { @@ -677,12 +686,10 @@ void generateModels(List files, List allModels, List unu // external type (e.g. --type-mappings Address=CustomAddress // --import-mappings CustomAddress=package:custom/address.dart). // The model metadata is still kept in allModels for use by supporting file templates. - if (config.typeMapping().containsKey(modelName)) { - String mappedTypeName = config.typeMapping().get(modelName); - if (config.importMapping().containsKey(mappedTypeName)) { - LOGGER.info("Model {} (type-mapped to {}) not generated due to import mapping", modelName, mappedTypeName); - continue; - } + if (isSuppressedByTypeAndImportMapping(modelName)) { + LOGGER.info("Model {} (type-mapped to {}) not generated due to import mapping", + modelName, config.typeMapping().get(modelName)); + continue; } // to generate model files @@ -1371,6 +1378,10 @@ public List generate() { throw new RuntimeException("missing config!"); } + if (!config.forcedGenerateSchemas().isEmpty()) { + forcedSchemaSupport(); + } + if (config.getGeneratorMetadata() == null) { LOGGER.warn("Generator '{}' is missing generator metadata!", config.getName()); } else { diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/ForcedSchemaSupport.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/ForcedSchemaSupport.java new file mode 100644 index 000000000000..7e1add7c5676 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/ForcedSchemaSupport.java @@ -0,0 +1,13 @@ +package org.openapitools.codegen; + +/** + * Internal capability implemented by generator families that support isolated forced-schema + * shadow generation. + */ +public interface ForcedSchemaSupport { + + /** + * Clears every cache whose values depend on schema or import mappings. + */ + void clearModelNameCache(); +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java index a6689d3535de..a684bf326683 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java @@ -2735,7 +2735,8 @@ private boolean hasType(String type) { * When set to true, remove "properties" attribute on schema other than "object" * since it should be ignored and may result in odd generated code * - * @param schema Schema + * @param schema Schema + * @return Schema */ protected void processNormalizeOtherThanObjectWithProperties(Schema schema) { if (getRule(REMOVE_PROPERTIES_FROM_TYPE_OTHER_THAN_OBJECT)) { diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java index de42edb2211e..ffd1ac7d5f2b 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java @@ -241,6 +241,8 @@ public CodegenConfigurator addSchemaMapping(String key, String value) { * use the mapped classes; {@code typeMapping} is preserved. * Use {@code "*"} ({@link CodegenConstants#FORCE_GENERATE_ALL_SCHEMAS}) to force-generate * all mapping-suppressed schemas at once. Unmapped schemas remain in normal generation. + * Supported generator families are Java, Groovy, Kotlin, C#, Python, Python Pydantic v1, PHP, Go + * client, Perl, PowerShell, R, and Ruby; other generators fail before writing files. */ public CodegenConfigurator addForcedGenerateSchema(String schema) { this.forcedGenerateSchemas.add(schema); @@ -252,6 +254,7 @@ public CodegenConfigurator addForcedGenerateSchema(String schema) { * Replaces the entire {@code forcedGenerateSchemas} set. * Use {@code "*"} ({@link CodegenConstants#FORCE_GENERATE_ALL_SCHEMAS}) as a wildcard * to force-generate all mapping-suppressed schemas at once. + * Unsupported generator families fail before writing files. */ public CodegenConfigurator setForcedGenerateSchemas(Set schemas) { this.forcedGenerateSchemas = schemas; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractCSharpCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractCSharpCodegen.java index 9ee362956295..4f694a2b1d43 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractCSharpCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractCSharpCodegen.java @@ -60,7 +60,7 @@ import static org.openapitools.codegen.utils.StringUtils.camelize; import static org.openapitools.codegen.utils.StringUtils.underscore; -public abstract class AbstractCSharpCodegen extends DefaultCodegen { +public abstract class AbstractCSharpCodegen extends DefaultCodegen implements ForcedSchemaSupport { protected boolean optionalAssemblyInfoFlag = true; protected boolean optionalEmitDefaultValuesFlag = false; @@ -131,7 +131,6 @@ public void clearModelNameCache() { super.clearModelNameCache(); } - // A cache to efficiently lookup CodegenModel `fromModel(codegenModelName, parentModelSchema)` based on the pair of model name and schema private final Map, CodegenModel> codegenModelNameAndSchemaKeyToCodegenModelCache = new HashMap<>(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java index a8c93c58ef35..af37058f66a7 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractJavaCodegen.java @@ -81,7 +81,7 @@ import static org.openapitools.codegen.utils.OnceLogger.once; import static org.openapitools.codegen.utils.StringUtils.*; -public abstract class AbstractJavaCodegen extends DefaultCodegen implements CodegenConfig, +public abstract class AbstractJavaCodegen extends DefaultCodegen implements CodegenConfig, ForcedSchemaSupport, DocumentationProviderFeatures { private final Logger LOGGER = LoggerFactory.getLogger(AbstractJavaCodegen.class); @@ -238,7 +238,6 @@ public void clearModelNameCache() { super.clearModelNameCache(); } - public AbstractJavaCodegen() { super(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java index 14588832766c..65297f224de9 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java @@ -49,7 +49,7 @@ import static org.openapitools.codegen.utils.CamelizeOption.LOWERCASE_FIRST_LETTER; import static org.openapitools.codegen.utils.StringUtils.*; -public abstract class AbstractKotlinCodegen extends DefaultCodegen implements CodegenConfig { +public abstract class AbstractKotlinCodegen extends DefaultCodegen implements CodegenConfig, ForcedSchemaSupport { public static final String MODEL_MUTABLE = "modelMutable"; public static final String MODEL_MUTABLE_DESC = "Create mutable models"; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPhpCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPhpCodegen.java index 0c8e2108dc2c..89bdc3626ae6 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPhpCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPhpCodegen.java @@ -47,7 +47,7 @@ import static org.openapitools.codegen.utils.StringUtils.camelize; import static org.openapitools.codegen.utils.StringUtils.underscore; -public abstract class AbstractPhpCodegen extends DefaultCodegen implements CodegenConfig { +public abstract class AbstractPhpCodegen extends DefaultCodegen implements CodegenConfig, ForcedSchemaSupport { private final Logger LOGGER = LoggerFactory.getLogger(AbstractPhpCodegen.class); @@ -83,7 +83,6 @@ public void clearModelNameCache() { super.clearModelNameCache(); } - public AbstractPhpCodegen() { super(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java index e0e819615042..e8ba620a8114 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonCodegen.java @@ -51,7 +51,7 @@ import static org.openapitools.codegen.utils.StringUtils.*; -public abstract class AbstractPythonCodegen extends DefaultCodegen implements CodegenConfig { +public abstract class AbstractPythonCodegen extends DefaultCodegen implements CodegenConfig, ForcedSchemaSupport { private final Logger LOGGER = LoggerFactory.getLogger(AbstractPythonCodegen.class); public static final String MAP_NUMBER_TO = "mapNumberTo"; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonPydanticV1Codegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonPydanticV1Codegen.java index b56fcdf9f5f0..6dafeb4cac7d 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonPydanticV1Codegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonPydanticV1Codegen.java @@ -47,7 +47,7 @@ import static org.openapitools.codegen.utils.ModelUtils.*; import static org.openapitools.codegen.utils.StringUtils.*; -public abstract class AbstractPythonPydanticV1Codegen extends DefaultCodegen implements CodegenConfig { +public abstract class AbstractPythonPydanticV1Codegen extends DefaultCodegen implements CodegenConfig, ForcedSchemaSupport { private final Logger LOGGER = LoggerFactory.getLogger(AbstractPythonPydanticV1Codegen.class); public static final String MAP_NUMBER_TO = "mapNumberTo"; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GoClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GoClientCodegen.java index ed2706864f99..8c5bf30082ae 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GoClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GoClientCodegen.java @@ -51,7 +51,7 @@ /** *

Mustache templates are located in {@code src/main/resources/go/}. */ -public class GoClientCodegen extends AbstractGoCodegen { +public class GoClientCodegen extends AbstractGoCodegen implements ForcedSchemaSupport { private final Logger LOGGER = LoggerFactory.getLogger(GoClientCodegen.class); @Setter protected String packageVersion = "1.0.0"; @@ -79,7 +79,6 @@ public void clearModelNameCache() { super.clearModelNameCache(); } - public GoClientCodegen() { super(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java index 2939c0841a16..f948e4da1d3e 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java @@ -78,17 +78,24 @@ private PagedModelScanUtils() {} * names are identical. When {@link #scanPagedModels(OpenAPI, UnaryOperator)} is used, they * may differ (e.g. {@code rawSchemaName="UserPage"}, {@code schemaName="UserPageDto"}).

* + * @param schemaName Transformed model name of the detected paged schema. + * @param itemSchemaName Raw item schema name (always raw; callers apply + * {@code toModelName()} at the point of use). + * @param metaSchemaName Transformed model name of the pagination-metadata schema, + * or {@code null} if unresolved. + * @param rawSchemaName Raw OpenAPI schema name of the paged schema (for {@code objs.remove}). + * @param rawMetaSchemaName Raw OpenAPI schema name of the pagination-metadata schema + * (for {@code objs.remove}), or {@code null} if unresolved. */ public static final class DetectedPagedModel { /** Transformed model name — use for import removal / import-presence checks. */ public final String schemaName; - /** Raw item schema name; callers apply {@code toModelName()} at the point of use. */ public final String itemSchemaName; - /** Transformed meta model name for import checks, or {@code null} if unresolved. */ + /** Transformed meta model name — use for import-presence checks. */ public final String metaSchemaName; /** Raw OpenAPI schema name — use for {@code objs.remove()} in {@code postProcessAllModels}. */ public final String rawSchemaName; - /** Raw OpenAPI meta schema name for {@code objs.remove()}, or {@code null} if unresolved. */ + /** Raw OpenAPI meta schema name — use for {@code objs.remove()} in {@code postProcessAllModels}. */ public final String rawMetaSchemaName; /** diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PerlClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PerlClientCodegen.java index 69e4e5169695..b0cce8cbdbea 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PerlClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PerlClientCodegen.java @@ -40,7 +40,7 @@ /** *

Mustache templates are located in {@code src/main/resources/perl/}. */ -public class PerlClientCodegen extends DefaultCodegen implements CodegenConfig { +public class PerlClientCodegen extends DefaultCodegen implements CodegenConfig, ForcedSchemaSupport { private final Logger LOGGER = LoggerFactory.getLogger(PerlClientCodegen.class); protected static int emptyFunctionNameCounter = 0; @@ -60,7 +60,6 @@ public void clearModelNameCache() { super.clearModelNameCache(); } - public PerlClientCodegen() { super(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PowerShellClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PowerShellClientCodegen.java index b842af9ec8ff..d3ef1acdbc27 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PowerShellClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PowerShellClientCodegen.java @@ -46,7 +46,7 @@ /** *

Mustache templates are located in {@code src/main/resources/powershell/}. */ -public class PowerShellClientCodegen extends DefaultCodegen implements CodegenConfig { +public class PowerShellClientCodegen extends DefaultCodegen implements CodegenConfig, ForcedSchemaSupport { private final Logger LOGGER = LoggerFactory.getLogger(PowerShellClientCodegen.class); @Setter private String packageGuid = "{" + randomUUID().toString().toUpperCase(Locale.ROOT) + "}"; @@ -83,7 +83,6 @@ public void clearModelNameCache() { super.clearModelNameCache(); } - /** * Constructs an instance of `PowerShellClientCodegen`. */ diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RClientCodegen.java index 72ac2b4a5027..b6fe16975bfa 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RClientCodegen.java @@ -51,7 +51,7 @@ * {@code src/main/resources/r/libraries/} (library-specific overrides). * A library-specific template shadows a root-level template of the same name. */ -public class RClientCodegen extends DefaultCodegen implements CodegenConfig { +public class RClientCodegen extends DefaultCodegen implements CodegenConfig, ForcedSchemaSupport { private final Logger LOGGER = LoggerFactory.getLogger(RClientCodegen.class); @Setter protected String packageName = "openapi"; @@ -91,7 +91,6 @@ public void clearModelNameCache() { super.clearModelNameCache(); } - @Override public CodegenType getTag() { return CodegenType.CLIENT; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RubyClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RubyClientCodegen.java index 780fa96dbfdd..f2900d250564 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RubyClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RubyClientCodegen.java @@ -46,7 +46,7 @@ /** *

Mustache templates are located in {@code src/main/resources/ruby-client/}. */ -public class RubyClientCodegen extends AbstractRubyCodegen { +public class RubyClientCodegen extends AbstractRubyCodegen implements ForcedSchemaSupport { public static final String GEM_VERSION = "gemVersion"; public static final String GEM_LICENSE = "gemLicense"; public static final String GEM_REQUIRED_RUBY_VERSION = "gemRequiredRubyVersion"; @@ -88,7 +88,6 @@ public void clearModelNameCache() { super.clearModelNameCache(); } - public RubyClientCodegen() { super(); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java index d51880306894..f64d7efba594 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java @@ -50,7 +50,7 @@ public class SpringPageableScanUtils { /** * The three Spring Data Web query-parameter names that together signal a - * {@code Pageable} operation: + * {@link org.springframework.data.domain.Pageable} operation: * {@code page}, {@code size}, and {@code sort}. * *

Use this constant instead of repeating {@code Arrays.asList("page", "size", "sort")} @@ -274,7 +274,7 @@ public static boolean applyAutoXSpringPaginatedIfNeeded( * {@code size}, {@code sort}) from the given codegen operation's parameter lists. * *

When an operation is marked with {@code x-spring-paginated}, Spring injects a single - * {@code Pageable} parameter that internally handles + * {@link org.springframework.data.domain.Pageable} parameter that internally handles * {@code page}, {@code size}, and {@code sort}. The individual query parameters must * therefore be removed from the generated method signature.

* 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 7a1417054c2d..fe612c09dcd9 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 @@ -2512,7 +2512,7 @@ public static String getType(Schema schema) { * For 3.1 spec, set as types, for 3.0, type * * @param schema the schema - * @param type the schema type + * @return schema type */ public static void setType(Schema schema, String type) { if (schema instanceof JsonSchema) { diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java index 72f83d5c48bc..ddf7dc16ddd4 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java @@ -555,6 +555,20 @@ public void forcedGenerateSchemaOverridesSchemaMappingSkip() throws IOException } } + @Test + public void forcedGenerateSchemasFailsFastForUnsupportedGenerator() { + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("clojure") + .setInputSpec("src/test/resources/3_0/petstore.yaml") + .addForcedGenerateSchema("Category"); + + IllegalArgumentException exception = Assert.expectThrows( + IllegalArgumentException.class, + () -> new DefaultGenerator(false).opts(configurator.toClientOptInput()).generate()); + + Assert.assertTrue(exception.getMessage().contains("Generator 'clojure' does not support forcedGenerateSchemas")); + } + @Test public void testNonStrictProcessPaths() throws Exception { OpenAPI openAPI = TestUtils.createOpenAPI(); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/ForcedGenerateSchemasSupportedFamiliesTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/ForcedGenerateSchemasSupportedFamiliesTest.java new file mode 100644 index 000000000000..0a3c46d8a4fd --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/ForcedGenerateSchemasSupportedFamiliesTest.java @@ -0,0 +1,91 @@ +package org.openapitools.codegen; + +import org.openapitools.codegen.languages.CSharpClientCodegen; +import org.openapitools.codegen.languages.GoClientCodegen; +import org.openapitools.codegen.languages.GroovyClientCodegen; +import org.openapitools.codegen.languages.JavaClientCodegen; +import org.openapitools.codegen.languages.KotlinClientCodegen; +import org.openapitools.codegen.languages.PerlClientCodegen; +import org.openapitools.codegen.languages.PhpClientCodegen; +import org.openapitools.codegen.languages.PowerShellClientCodegen; +import org.openapitools.codegen.languages.PythonClientCodegen; +import org.openapitools.codegen.languages.PythonPydanticV1ClientCodegen; +import org.openapitools.codegen.languages.RClientCodegen; +import org.openapitools.codegen.languages.RubyClientCodegen; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +public class ForcedGenerateSchemasSupportedFamiliesTest { + + private static final String SPEC = "src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml"; + + @DataProvider(name = "supportedGenerators") + public Object[][] supportedGenerators() { + return new Object[][]{ + {new JavaClientCodegen()}, + {new GroovyClientCodegen()}, + {new KotlinClientCodegen()}, + {new CSharpClientCodegen()}, + {new PythonClientCodegen()}, + {new PythonPydanticV1ClientCodegen()}, + {new PhpClientCodegen()}, + {new GoClientCodegen()}, + {new PerlClientCodegen()}, + {new PowerShellClientCodegen()}, + {new RClientCodegen()}, + {new RubyClientCodegen()} + }; + } + + @Test(dataProvider = "supportedGenerators") + public void supportedGeneratorEmitsStockShadowModelAndRestoresMappings(CodegenConfig codegen) throws Exception { + File output = Files.createTempDirectory("forced-gen-" + codegen.getName()).toFile().getCanonicalFile(); + output.deleteOnExit(); + + String stockFilename = codegen.toModelFilename("Widget"); + String stockRelatedModelName = codegen.toModelName("Group"); + String mappedName = "com.example.mapped.Widget"; + String mappedRelatedName = "com.example.mapped.Group"; + codegen.setOutputDir(output.getAbsolutePath()); + codegen.schemaMapping().put("Widget", mappedName); + codegen.schemaMapping().put("Group", mappedRelatedName); + codegen.forcedGenerateSchemas().addAll(List.of("Widget", "Group")); + + DefaultGenerator generator = new DefaultGenerator(); + generator.setGenerateMetadata(false); + generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true"); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_TESTS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_DOCS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false"); + + List generatedFiles = generator.opts(new ClientOptInput() + .openAPI(TestUtils.parseSpec(SPEC)) + .config(codegen)) + .generate(); + + File widgetFile = generatedFiles.stream() + .filter(file -> file.getName().contains(stockFilename)) + .findFirst() + .orElseThrow(() -> new AssertionError( + codegen.getName() + " must emit the forced schema under its stock filename")); + String widgetContents = Files.readString(Path.of(widgetFile.toURI())); + assertTrue(widgetContents.contains(stockRelatedModelName), + codegen.getName() + " must use the stock name for a reference between shadow models"); + assertFalse(widgetContents.contains(mappedRelatedName), + codegen.getName() + " must not leak mapped names into shadow models"); + assertEquals(codegen.schemaMapping().get("Widget"), mappedName, + codegen.getName() + " must restore schema mappings after the shadow pass"); + assertEquals(codegen.schemaMapping().get("Group"), mappedRelatedName, + codegen.getName() + " must restore all schema mappings after the shadow pass"); + } +} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java index 3e0004e2d05a..5933f52c6ecc 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java @@ -23,7 +23,7 @@ *

* This mirrors {@code ForcedGenerateSchemasKotlinTest} on a different generator family to * demonstrate that the two-phase forced-schema generation in {@link DefaultGenerator} is - * generator-agnostic — it works with no per-generator code. The same generic model graph is used + * supported across representative generator families. The same generic model graph is used * (Widget/Group/Shape/Circle/Square are mapped to hand-written classes but also force-generated). */ public class ForcedGenerateSchemasCSharpTest { diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java index 52dfe38f3371..c1ef5d216892 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java @@ -6,6 +6,7 @@ import org.openapitools.codegen.TestUtils; import org.openapitools.codegen.languages.SpringCodegen; import org.openapitools.codegen.model.ModelMap; +import org.openapitools.codegen.model.ModelsMap; import org.testng.annotations.Test; import java.io.File; @@ -88,6 +89,18 @@ public Map postProcessSupportingFileData(Map obj } } + private static class ShadowMutatingSpringCodegen extends SpringCodegen { + private int modelPasses; + + @Override + public Map postProcessAllModels(Map objs) { + if (++modelPasses > 1) { + additionalProperties.put("shadowPassLeak", true); + } + return super.postProcessAllModels(objs); + } + } + @Test public void forcedFqnMappedSchemasAreGeneratedWithValidStockNames() throws IOException { File output = Files.createTempDirectory("forced-gen-spring").toFile().getCanonicalFile(); @@ -195,4 +208,15 @@ public void wildcardForcesAllMappedSchemas() throws IOException { assertFileContains(Paths.get(modelDir + File.separator + "ApiContainer.java"), "com.example.mapped.Widget"); assertFileNotContains(Paths.get(modelDir + File.separator + "ApiContainer.java"), "ApiWidget"); } + + @Test + public void shadowPassRestoresMutableGeneratorProperties() throws IOException { + File output = Files.createTempDirectory("forced-gen-spring").toFile().getCanonicalFile(); + output.deleteOnExit(); + ShadowMutatingSpringCodegen codegen = new ShadowMutatingSpringCodegen(); + + generate(output, codegen, false, "Widget"); + + assertFalse(codegen.additionalProperties().containsKey("shadowPassLeak")); + } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/AbstractKotlinCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/AbstractKotlinCodegenTest.java index a41847474210..e155b0b00ff0 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/AbstractKotlinCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/AbstractKotlinCodegenTest.java @@ -35,6 +35,7 @@ public class AbstractKotlinCodegenTest { private AbstractKotlinCodegen codegen; /** + * In TEST-NG, test class (and its fields) is only constructed once (vs. for every test in Jupiter), * using @BeforeMethod to have a fresh codegen mock for each test */ @BeforeMethod From f3db0469111adf86feb5fb0a89d2f4af4445299c Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 25 Aug 2026 09:30:21 +0200 Subject: [PATCH 08/11] fix: enhance forced schema generation to correctly handle recursive dependents and suppress mapping leaks --- .../codegen/DefaultGenerator.java | 25 ++++-- .../codegen/DefaultGeneratorTest.java | 83 +++++++++++++++++++ .../ForcedGenerateSchemasCSharpTest.java | 7 +- .../3_0/forced-recursive-dependent.yaml | 32 +++++++ .../java/com/example/mapped/Category.java | 4 + .../kotlin/com/example/mapped/Category.kt | 7 +- 6 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_0/forced-recursive-dependent.yaml diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java index e03d26b558b4..43cf86d2eec0 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java @@ -631,18 +631,25 @@ private void generateModels(List files, List allModels, List entry : allProcessedModels.entrySet()) { + // During the shadow pass only walk the forced schemas: their dependents that live + // outside the (constrained) model set must still be emitted, whereas non-forced + // models already had their dependents resolved in the normal pass. + if (shadowPass && !modelsToEmit.contains(entry.getKey())) { + continue; + } + for (ModelMap mm : entry.getValue().getModels()) { CodegenModel cm = mm.getModel(); if (cm != null) { for (CodegenProperty variable : cm.getVars()) { - generateModelsForVariable(files, allModels, unusedModels, aliasModels, processedModels, variable); + generateModelsForVariable(files, allModels, unusedModels, aliasModels, processedModels, variable, shadowPass); } //TODO: handle interfaces String parentSchema = cm.getParentSchema(); if (parentSchema != null && !processedModels.contains(parentSchema) && ModelUtils.getSchemas(this.openAPI).containsKey(parentSchema)) { - generateModels(files, allModels, unusedModels, aliasModels, processedModels, () -> Set.of(parentSchema)); + generateModels(files, allModels, unusedModels, aliasModels, processedModels, () -> Set.of(parentSchema), + shadowPass, shadowPass ? Set.of(parentSchema) : Collections.emptySet()); } } } @@ -714,7 +721,7 @@ private void generateModels(List files, List allModels, List files, List allModels, List unusedModels, List aliasModels, List processedModels, CodegenProperty variable) { + private void generateModelsForVariable(List files, List allModels, List unusedModels, List aliasModels, List processedModels, CodegenProperty variable, boolean shadowPass) { if (variable == null) { return; } @@ -722,12 +729,14 @@ private void generateModelsForVariable(List files, List allModel final String schemaKey = calculateModelKey(variable.getOpenApiType(), variable.getRef()); Map allSchemas = ModelUtils.getSchemas(this.openAPI); if (!processedModels.contains(schemaKey) && allSchemas.containsKey(schemaKey)) { - generateModels(files, allModels, unusedModels, aliasModels, processedModels, () -> Set.of(schemaKey)); + generateModels(files, allModels, unusedModels, aliasModels, processedModels, () -> Set.of(schemaKey), + shadowPass, shadowPass ? Set.of(schemaKey) : Collections.emptySet()); } else if (variable.getComplexType() != null && variable.getComposedSchemas() == null) { String ref = variable.getHasItems() ? variable.getItems().getRef() : variable.getRef(); final String key = calculateModelKey(variable.getComplexType(), ref); if (!processedModels.contains(key) && allSchemas.containsKey(key)) { - generateModels(files, allModels, unusedModels, aliasModels, processedModels, () -> Set.of(key)); + generateModels(files, allModels, unusedModels, aliasModels, processedModels, () -> Set.of(key), + shadowPass, shadowPass ? Set.of(key) : Collections.emptySet()); } else { LOGGER.info("Type {} of variable {} could not be resolve because it is not declared as a model.", variable.getComplexType(), variable.getName()); } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java index ddf7dc16ddd4..b7743d6015c8 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java @@ -525,6 +525,36 @@ public void forcedGenerateSchemaOverridesSchemaMappingSkip() throws IOException } // --- Part 4: typeMapping + importMapping suppression must also be bypassed --- + // Part 4a (control): typeMapping + importMapping alone must suppress Category in Phase 1. + Path target4control = Files.createTempDirectory("test-forced-gen-type-import-control"); + try { + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("java") + .setInputSpec("src/test/resources/3_0/petstore.yaml") + .setOutputDir(target4control.toAbsolutePath().toString()) + .addTypeMapping("Category", "ExternalCategory") + .addImportMapping("ExternalCategory", "com.example.ExternalCategory"); + + DefaultGenerator generator = new DefaultGenerator(false); + generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true"); + generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_TESTS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_DOCS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false"); + + List files = generator.opts(configurator.toClientOptInput()).generate(); + + Assert.assertFalse( + files.stream().anyMatch(f -> f.getPath().replace('\\', '/').endsWith(originalModelRelPath)), + "Category.java must NOT be generated when type/import mapping suppresses it and it is not forced"); + Assert.assertFalse( + new File(target4control.toFile(), originalModelRelPath).exists(), + "Category.java must NOT exist when type/import mapping suppresses it and it is not forced"); + } finally { + target4control.toFile().deleteOnExit(); + } + + // Part 4b: forcing Category must bypass the type/import mapping suppression. Path target4 = Files.createTempDirectory("test-forced-gen-type-import"); try { final CodegenConfigurator configurator = new CodegenConfigurator() @@ -555,6 +585,59 @@ public void forcedGenerateSchemaOverridesSchemaMappingSkip() throws IOException } } + /** + * Regression test: when the top-level model set is constrained and + * {@code generateRecursiveDependentModels} is enabled, a forced+mapped schema's dependents that + * are reachable only through it must still be emitted by the forced-schema pass. Previously the + * recursive-dependent discovery was disabled during the forced pass, silently dropping them. + */ + @Test + public void forcedGenerateSchemaKeepsRecursiveDependentsOfMappedSchema() throws IOException { + final String rootRelPath = "src/main/java/org/openapitools/client/model/Root.java"; + final String dependentRelPath = "src/main/java/org/openapitools/client/model/RecursiveDependent.java"; + final String mappedRootRelPath = "src/main/java/org/openapitools/client/model/ExternalRoot.java"; + + Path target = Files.createTempDirectory("test-forced-gen-recursive"); + String oldModelsProp = GlobalSettings.getProperty(CodegenConstants.MODELS); + try { + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("java") + .setInputSpec("src/test/resources/3_0/forced-recursive-dependent.yaml") + .setOutputDir(target.toAbsolutePath().toString()) + .addSchemaMapping("Root", "ExternalRoot") + .addForcedGenerateSchema("Root"); + + DefaultGenerator generator = new DefaultGenerator(false); + generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_TESTS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_DOCS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.GENERATE_RECURSIVE_DEPENDENT_MODELS, "true"); + // Constrain the top-level model set to Root only; RecursiveDependent is reachable only + // through Root and must be pulled in recursively. + GlobalSettings.setProperty(CodegenConstants.MODELS, "Root"); + + generator.opts(configurator.toClientOptInput()).generate(); + + Assert.assertTrue( + new File(target.toFile(), rootRelPath).exists(), + "Root.java (stock name) MUST be generated for the forced+mapped schema"); + Assert.assertFalse( + new File(target.toFile(), mappedRootRelPath).exists(), + "ExternalRoot.java (mapped name) must NOT be generated: the forced schema bypasses the mapping"); + Assert.assertTrue( + new File(target.toFile(), dependentRelPath).exists(), + "RecursiveDependent.java MUST be generated as a recursive dependent of the forced schema"); + } finally { + if (oldModelsProp != null) { + GlobalSettings.setProperty(CodegenConstants.MODELS, oldModelsProp); + } else { + GlobalSettings.clearProperty(CodegenConstants.MODELS); + } + target.toFile().deleteOnExit(); + } + } + @Test public void forcedGenerateSchemasFailsFastForUnsupportedGenerator() { final CodegenConfigurator configurator = new CodegenConfigurator() diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java index 5933f52c6ecc..3956780f3223 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java @@ -69,11 +69,16 @@ public void forcedFqnMappedSchemasAreGeneratedWithStockNames() throws IOExceptio File modelDir = generate(output, "Widget", "Group", "Shape", "Circle", "Square"); // The forced+mapped schemas are emitted as stock ApiXxx classes despite the FQN mapping. - for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiCircle", "ApiSquare")) { + for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiShape", "ApiCircle", "ApiSquare")) { assertTrue(new File(modelDir, name + ".cs").exists(), name + ".cs must be generated"); } assertFileContains(Paths.get(modelDir + File.separator + "ApiWidget.cs"), "class ApiWidget"); + // No forced model declaration or reference leaks the mapped FQN. + for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiShape", "ApiCircle", "ApiSquare")) { + assertFileNotContains(Paths.get(modelDir + File.separator + name + ".cs"), "Com.Example.Mapped."); + } + // Container is neither mapped nor forced: its reference to Widget keeps the mapped class. assertFileContains(Paths.get(modelDir + File.separator + "ApiContainer.cs"), "Com.Example.Mapped.Widget"); } diff --git a/modules/openapi-generator/src/test/resources/3_0/forced-recursive-dependent.yaml b/modules/openapi-generator/src/test/resources/3_0/forced-recursive-dependent.yaml new file mode 100644 index 000000000000..e6bd3cab1b67 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/forced-recursive-dependent.yaml @@ -0,0 +1,32 @@ +openapi: 3.0.0 +info: + title: forcedGenerateSchemas with recursive dependent models + description: > + Root is mapped via schemaMappings and also force-generated. When the top-level model set is + constrained to Root only and generateRecursiveDependentModels is enabled, Root's dependent + (RecursiveDependent) is reachable exclusively through Root and must still be emitted by the + forced-schema pass. + version: 1.0.0 +paths: + /root: + get: + operationId: getRoot + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Root' +components: + schemas: + Root: + type: object + properties: + dependent: + $ref: '#/components/schemas/RecursiveDependent' + RecursiveDependent: + type: object + properties: + value: + type: string diff --git a/samples/openapi3/server/petstore/springboot-3/src/main/java/com/example/mapped/Category.java b/samples/openapi3/server/petstore/springboot-3/src/main/java/com/example/mapped/Category.java index 4fa657f481e3..24129182b737 100644 --- a/samples/openapi3/server/petstore/springboot-3/src/main/java/com/example/mapped/Category.java +++ b/samples/openapi3/server/petstore/springboot-3/src/main/java/com/example/mapped/Category.java @@ -1,11 +1,14 @@ package com.example.mapped; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Pattern; import org.springframework.lang.Nullable; /** * Handwritten production model used through the Category schema mapping. */ +@JsonInclude(JsonInclude.Include.NON_NULL) public class Category { private @Nullable Long id; private @Nullable String name; @@ -28,6 +31,7 @@ public void setId(@Nullable Long id) { } @JsonProperty("name") + @Pattern(regexp = "^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$") public @Nullable String getName() { return name; } diff --git a/samples/server/petstore/kotlin-springboot-3/src/main/kotlin/com/example/mapped/Category.kt b/samples/server/petstore/kotlin-springboot-3/src/main/kotlin/com/example/mapped/Category.kt index b719bdfce27a..94d03a4b4d70 100644 --- a/samples/server/petstore/kotlin-springboot-3/src/main/kotlin/com/example/mapped/Category.kt +++ b/samples/server/petstore/kotlin-springboot-3/src/main/kotlin/com/example/mapped/Category.kt @@ -11,4 +11,9 @@ data class Category( val id: Long? = null, @get:JsonProperty("name") val name: String? = null -) : Serializable +) : Serializable { + + companion object { + private const val serialVersionUID: Long = 1 + } +} From 00a447d6bb82409a88acbc608a720d373fac931a Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 25 Aug 2026 13:55:37 +0200 Subject: [PATCH 09/11] fix: update supporting model names collection to use stream collectors for improved readability --- .../codegen/java/spring/ForcedGenerateSchemasSpringTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java index c1ef5d216892..a502c6d6df4a 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java @@ -18,6 +18,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import static org.openapitools.codegen.TestUtils.assertFileContains; import static org.openapitools.codegen.TestUtils.assertFileNotContains; @@ -188,7 +189,7 @@ public void forcedSchemasDoNotLeakIntoApisOrSupportingModelMetadata() throws IOE List supportingModelNames = codegen.supportingModels.stream() .map(ModelMap::getModel) .map(model -> model.classname) - .toList(); + .collect(Collectors.toList()); assertTrue(supportingModelNames.contains("ApiContainer")); assertFalse(supportingModelNames.contains("ApiWidget")); } From de87b23f3a8f27f8e691a996f3d67a8b1baaf512 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 25 Aug 2026 15:51:09 +0200 Subject: [PATCH 10/11] fix: enhance forced schema generation to prevent mapping leaks and ensure correct model reference handling --- .../codegen/DefaultGenerator.java | 26 ++++++++++---- .../languages/AbstractCSharpCodegen.java | 1 + ...dGenerateSchemasSupportedFamiliesTest.java | 34 +++++++++++++++++++ .../ForcedGenerateSchemasCSharpTest.java | 6 +++- .../ForcedGenerateSchemasSpringTest.java | 2 +- 5 files changed, 60 insertions(+), 9 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java index 43cf86d2eec0..cb28c7358631 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java @@ -83,6 +83,12 @@ public class DefaultGenerator implements Generator { private Boolean generateModelTests = null; private Boolean generateModelDocumentation = null; private Boolean generateMetadata = true; + /** + * Model keys emitted during the primary (non-shadow) model pass. Used by the forced-schema + * shadow pass to avoid re-emitting (and thereby overwriting with stock-name references) a + * recursive dependent that was already generated with its mapped references in the primary pass. + */ + private final Set primaryPassEmittedModels = new HashSet<>(); private String basePath; private String basePathWithoutHost; private String contextPath; @@ -470,6 +476,7 @@ private void generateModel(List files, Map models, String } void generateModels(List files, List allModels, List unusedModels, List aliasModels) { + primaryPassEmittedModels.clear(); generateModels(files, allModels, unusedModels, aliasModels, new ArrayList<>(), DefaultGenerator.this::modelKeys, false, Collections.emptySet()); } @@ -511,10 +518,6 @@ void generateForcedModels(List files) { for (String schemaName : forcedSet) { config.schemaMapping().remove(schemaName); config.importMapping().remove(schemaName); - String mappedTypeName = config.typeMapping().get(schemaName); - if (mappedTypeName != null) { - config.importMapping().remove(mappedTypeName); - } } support.clearModelNameCache(); @@ -661,8 +664,10 @@ private void generateModels(List files, List allModels, List files, List allModels, List file.getName().contains(containerFilename)) + .findFirst() + .orElseThrow(() -> new AssertionError( + codegen.getName() + " must emit the non-forced Container model")); + String containerContents = Files.readString(Path.of(containerFile.toURI())); + + // The forced shadow Widget must never reference the mapped Widget name: the mapped + // reference belongs exclusively to non-forced consumers such as Container. + assertFalse(widgetContents.contains(mappedName), + codegen.getName() + " must not leak the mapped name into the forced shadow model"); + + // Families whose type system can carry a dotted, fully-qualified schemaMapping verbatim keep + // the mapped reference in the non-forced Container. The remaining families cannot represent a + // dotted name and sanitize it to a stock-like reference, so the mapped/stock distinction is + // not observable in their output; for those we only require that Container was emitted. + Set fqnMappingFamilies = Set.of( + "java", "groovy", "kotlin", "csharp", "python", "perl", "r", "ruby"); + if (fqnMappingFamilies.contains(codegen.getName())) { + assertTrue(containerContents.contains(mappedName), + codegen.getName() + " must keep the mapped reference in the non-forced Container model"); + } + assertEquals(codegen.schemaMapping().get("Widget"), mappedName, codegen.getName() + " must restore schema mappings after the shadow pass"); assertEquals(codegen.schemaMapping().get("Group"), mappedRelatedName, diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java index 3956780f3223..b71b749884d5 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java @@ -90,9 +90,13 @@ public void wildcardForcesAllMappedSchemas() throws IOException { File modelDir = generate(output, CodegenConstants.FORCE_GENERATE_ALL_SCHEMAS); - for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiCircle", "ApiSquare", "ApiContainer")) { + for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiShape", "ApiCircle", "ApiSquare", "ApiContainer")) { assertTrue(new File(modelDir, name + ".cs").exists(), name + ".cs must be generated with the wildcard"); } + // The forced (mapping-suppressed) schemas never leak the mapped FQN into their stock files. + for (String name : Arrays.asList("ApiWidget", "ApiGroup", "ApiShape", "ApiCircle", "ApiSquare")) { + assertFileNotContains(Paths.get(modelDir + File.separator + name + ".cs"), "Com.Example.Mapped."); + } // The wildcard selects only mapping-suppressed schemas. Container remains a normal model, // so its reference continues to use the mapped production class. assertFileContains(Paths.get(modelDir + File.separator + "ApiContainer.cs"), "Com.Example.Mapped.Widget"); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java index a502c6d6df4a..0bf7ea65bf59 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java @@ -41,7 +41,7 @@ public class ForcedGenerateSchemasSpringTest { private static final String SPEC = "src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml"; - private static final String MODEL_DIR = "/src/main/java/org/openapitools/model/"; + private static final String MODEL_DIR = "src/main/java/org/openapitools/model/"; private File generate(File output, String... forcedSchemas) { return generate(output, new SpringCodegen(), false, forcedSchemas); From f351bd8bd41d1b6a84360f4930c78029f375377a Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 25 Aug 2026 16:53:11 +0200 Subject: [PATCH 11/11] fix: clear cached CodegenProperty during forced shadow pass and verify API isolation Enabling APIS in ForcedGenerateSchemasSupportedFamiliesTest exposed that the forced shadow pass reused a CodegenProperty cached during api generation (with the schemaMapping intact), leaking the mapped name into the stock shadow model (observed in the R generator's widget.R). Clear schemaCodegenPropertyCache in DefaultCodegen.clearModelNameCache() so properties are rebuilt with the current mappings. The test now keeps APIS enabled and asserts non-forced API artifacts still reference the mapped class. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openapitools/codegen/DefaultCodegen.java | 4 +++ ...dGenerateSchemasSupportedFamiliesTest.java | 25 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java index 82ef6cafbc5c..8599f5c3208e 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java @@ -1638,6 +1638,10 @@ public void clearModelNameCache() { // reset the lazily-built model-name -> schema index so it is rebuilt with the current // schemaMapping/importMapping state (used by the forced-schema generation pass). modelNameToSchemaCache = null; + // drop cached CodegenProperty instances so property data types are rebuilt with the current + // mappings; otherwise a property resolved earlier (e.g. while generating apis) with the + // mapping intact would be reused during the forced shadow pass and leak the mapped name. + schemaCodegenPropertyCache.clear(); } @Override diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/ForcedGenerateSchemasSupportedFamiliesTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/ForcedGenerateSchemasSupportedFamiliesTest.java index 2a6ff0332331..96c125bd3689 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/ForcedGenerateSchemasSupportedFamiliesTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/ForcedGenerateSchemasSupportedFamiliesTest.java @@ -69,7 +69,7 @@ public void supportedGeneratorEmitsStockShadowModelAndRestoresMappings(CodegenCo DefaultGenerator generator = new DefaultGenerator(); generator.setGenerateMetadata(false); generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true"); - generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "true"); generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_TESTS, "false"); generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_DOCS, "false"); generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false"); @@ -117,6 +117,29 @@ public void supportedGeneratorEmitsStockShadowModelAndRestoresMappings(CodegenCo codegen.getName() + " must keep the mapped reference in the non-forced Container model"); } + // API artifacts are non-forced consumers as well: the getWidget operation returns the + // mapped Widget, so for families that carry the dotted FQN the generated API must reference + // the mapped production class and must never be rewritten to the forced stock shadow name. + File apiFolder = new File(codegen.apiFileFolder()).getCanonicalFile(); + boolean anyApiFile = false; + boolean anyApiReferencesMapped = false; + for (File file : generatedFiles) { + File parent = file.getParentFile(); + if (parent == null + || !parent.getCanonicalPath().startsWith(apiFolder.getCanonicalPath())) { + continue; + } + anyApiFile = true; + if (Files.readString(Path.of(file.toURI())).contains(mappedName)) { + anyApiReferencesMapped = true; + } + } + assertTrue(anyApiFile, codegen.getName() + " must emit API artifacts"); + if (fqnMappingFamilies.contains(codegen.getName())) { + assertTrue(anyApiReferencesMapped, + codegen.getName() + " API artifacts must reference the mapped class, not the stock shadow"); + } + assertEquals(codegen.schemaMapping().get("Widget"), mappedName, codegen.getName() + " must restore schema mappings after the shadow pass"); assertEquals(codegen.schemaMapping().get("Group"), mappedRelatedName,