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/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..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 @@ -204,9 +204,11 @@ 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." + + " 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 9f18c8b85afb..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 @@ -256,8 +256,10 @@ 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. + * 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 */ @@ -974,9 +976,10 @@ 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. - * and returns a reference to this Builder so that the methods can be chained together. + * Sets the mapping-suppressed schemas to emit as isolated shadow models. + * 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 schemas the {@code forcedGenerateSchemas} to set * @return a reference to this Builder @@ -987,8 +990,9 @@ 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. + * 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 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..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 @@ -246,7 +246,12 @@ 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. + * 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 913ab08c96ed..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 @@ -641,7 +641,12 @@ 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. + * 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 084fab18b5c4..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 @@ -398,8 +398,10 @@ 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. + * 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 d9b3d8550e53..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,11 +166,22 @@ 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 + * 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. + *

+ * 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. */ 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..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 are generated even if they appear in schemaMappings or importMappings. + * 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 085f1d981597..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 @@ -184,8 +184,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<>(); @@ -1634,6 +1634,16 @@ public Set forcedGenerateSchemas() { return forcedGenerateSchemas; } + 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 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..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; @@ -404,15 +410,33 @@ 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 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) && 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 { @@ -452,10 +476,76 @@ 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); + primaryPassEmittedModels.clear(); + generateModels(files, allModels, unusedModels, aliasModels, new ArrayList<>(), DefaultGenerator.this::modelKeys, + false, Collections.emptySet()); + } + + /** + * Forced-schema generation pass (Phase 2). + * + *

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()) { + return; + } + + // Requested schemas that mappings suppressed in Phase 1. Emitted here as stock models. + Set forcedSet = modelKeys().stream() + .filter(this::isForcedShadowSchema) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + if (forcedSet.isEmpty()) { + return; + } + + LOGGER.info("Forced-schema generation pass: regenerating stock models for {}", forcedSet); + + Map savedSchemaMappings = new LinkedHashMap<>(config.schemaMapping()); + Map savedImportMappings = new LinkedHashMap<>(config.importMapping()); + Map savedAdditionalProperties = new LinkedHashMap<>(config.additionalProperties()); + ForcedSchemaSupport support = forcedSchemaSupport(); + + try { + for (String schemaName : forcedSet) { + config.schemaMapping().remove(schemaName); + config.importMapping().remove(schemaName); + } + 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<>(), new ArrayList<>(), DefaultGenerator.this::modelKeys, + true, forcedSet); + } finally { + config.schemaMapping().clear(); + config.schemaMapping().putAll(savedSchemaMappings); + config.importMapping().clear(); + config.importMapping().putAll(savedImportMappings); + 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."); @@ -478,8 +568,15 @@ void generateModels(List files, List allModels, List unu for (String name : modelKeys) { processedModels.add(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)) { + // 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; + } + + if (config.schemaMapping().containsKey(name)) { LOGGER.info("Model {} not generated due to schema mapping", name); continue; } @@ -538,17 +635,24 @@ void generateModels(List files, List allModels, List unu allProcessedModels = config.postProcessAllModels(allProcessedModels); if (generateRecursiveDependentModels) { - for (ModelsMap modelsMap : allProcessedModels.values()) { - for (ModelMap mm : modelsMap.getModels()) { + for (Map.Entry 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()); } } } @@ -559,9 +663,16 @@ void generateModels(List files, List allModels, List unu for (String modelName : allProcessedModels.keySet()) { ModelsMap models = allProcessedModels.get(modelName); 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. A recursive dependent + // already emitted in the primary pass (with its mapped references) is left untouched so + // the shadow pass does not overwrite it with stock-name references. + if (shadowPass && (!modelsToEmit.contains(modelName) || primaryPassEmittedModels.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)) { + // don't generate models that have a schema mapping + if (config.schemaMapping().containsKey(modelName)) { continue; } @@ -587,12 +698,17 @@ 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; - } + // A forced schema emitted during the shadow pass bypasses this suppression so its + // stock model file is produced; the import mapping itself is kept intact so any + // reference to the mapped type in the emitted source still resolves to an import. + if (!(shadowPass && modelsToEmit.contains(modelName)) && isSuppressedByTypeAndImportMapping(modelName)) { + LOGGER.info("Model {} (type-mapped to {}) not generated due to import mapping", + modelName, config.typeMapping().get(modelName)); + continue; + } + + if (!shadowPass) { + primaryPassEmittedModels.add(modelName); } // to generate model files @@ -617,7 +733,7 @@ void generateModels(List files, List allModels, List unu /** * this method guesses the schema type of in parent model used variable and if the schema type is available it let the generate the model for the type of this variable */ - private void generateModelsForVariable(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; } @@ -625,12 +741,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()); } @@ -1281,6 +1399,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 { @@ -1324,6 +1446,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/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/config/CodegenConfigurator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/config/CodegenConfigurator.java index ab15c320b2cd..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 @@ -236,9 +236,13 @@ 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. + * 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. + * 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); @@ -249,7 +253,8 @@ 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. + * 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 ab46ba2bb4b2..1fe6485e942a 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; @@ -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(); + codegenModelNameAndSchemaKeyToCodegenModelCache.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 554f98183fc7..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); @@ -232,6 +232,12 @@ 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(); 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..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"; @@ -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 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..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); @@ -77,6 +77,12 @@ 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..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"; @@ -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..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"; @@ -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..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"; @@ -73,6 +73,12 @@ 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..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; @@ -54,6 +54,12 @@ 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..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) + "}"; @@ -77,6 +77,12 @@ 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..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"; @@ -85,6 +85,12 @@ 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..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"; @@ -82,6 +82,12 @@ 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/DefaultGeneratorTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultGeneratorTest.java index 2119ec1c2853..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 @@ -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,24 +505,151 @@ 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(); } + + // --- 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() + .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(); + } + } + + /** + * 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() + .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 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..96c125bd3689 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/ForcedGenerateSchemasSupportedFamiliesTest.java @@ -0,0 +1,148 @@ +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 java.util.Set; + +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); + // The stock names above were resolved via toModelName/toModelFilename before schemaMapping + // was populated, which primes some generators' model-name cache with stock resolutions. + // Clear it so generation observes the mappings exactly as it would in production, where + // mappings are configured up front rather than discovered mid-run. + ((ForcedSchemaSupport) codegen).clearModelNameCache(); + codegen.forcedGenerateSchemas().addAll(List.of("Widget", "Group")); + + DefaultGenerator generator = new DefaultGenerator(); + generator.setGenerateMetadata(false); + generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true"); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "true"); + 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"); + + // The other half of the isolation contract: Container is neither mapped nor forced, so its + // reference to Widget must keep pointing at the mapped production class and must never be + // rewritten to the stock shadow name by the forced pass. + String containerFilename = codegen.toModelFilename("Container"); + File containerFile = generatedFiles.stream() + .filter(file -> 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"); + } + + // 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, + 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 new file mode 100644 index 000000000000..b71b749884d5 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/csharpnetcore/ForcedGenerateSchemasCSharpTest.java @@ -0,0 +1,105 @@ +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 + * 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 { + + 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", "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"); + } + + @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", "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"); + 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 new file mode 100644 index 000000000000..0bf7ea65bf59 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/ForcedGenerateSchemasSpringTest.java @@ -0,0 +1,223 @@ +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.openapitools.codegen.model.ModelMap; +import org.openapitools.codegen.model.ModelsMap; +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.ArrayList; +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; +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) { + 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); + 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, Boolean.toString(generateApis)); + 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); + } + + 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); + } + } + + 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(); + 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 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) + .collect(Collectors.toList()); + assertTrue(supportingModelNames.contains("ApiContainer")); + assertFalse(supportingModelNames.contains("ApiWidget")); + } + + @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"); + } + // 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"); + } + + @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/ForcedGenerateSchemasKotlinTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/ForcedGenerateSchemasKotlinTest.java new file mode 100644 index 000000000000..03b1df81b3fd --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/ForcedGenerateSchemasKotlinTest.java @@ -0,0 +1,165 @@ +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"); + } + // 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/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/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..0b0eaaef220f --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/kotlin/forced-generate-schema-mapping.yaml @@ -0,0 +1,85 @@ +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 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: + get: + operationId: getWidget + responses: + '200': + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' + /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 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..24129182b737 --- /dev/null +++ b/samples/openapi3/server/petstore/springboot-3/src/main/java/com/example/mapped/Category.java @@ -0,0 +1,42 @@ +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; + + 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") + @Pattern(regexp = "^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$") + 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..94d03a4b4d70 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-3/src/main/kotlin/com/example/mapped/Category.kt @@ -0,0 +1,19 @@ +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 { + + companion object { + private const val serialVersionUID: Long = 1 + } +} 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)