diff --git a/bin/configs/kotlin-spring-boot-generics.yaml b/bin/configs/kotlin-spring-boot-generics.yaml
new file mode 100644
index 000000000000..183a66b85428
--- /dev/null
+++ b/bin/configs/kotlin-spring-boot-generics.yaml
@@ -0,0 +1,30 @@
+generatorName: kotlin-spring
+outputDir: samples/server/petstore/kotlin-springboot-generics
+library: spring-boot
+inputSpec: modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics.yaml
+templateDir: modules/openapi-generator/src/main/resources/kotlin-spring
+additionalProperties:
+ documentationProvider: none
+ annotationLibrary: none
+ useSwaggerUI: "false"
+ serializableModel: "true"
+ useBeanValidation: "true"
+ interfaceOnly: "true"
+ skipDefaultInterface: "true"
+ useSpringBoot3: "true"
+ hideGenerationTimestamp: "true"
+ useTags: "true"
+ requestMappingMode: api_interface
+ genericPatterns:
+ - suffix: Response
+ genericClass: ApiResponse
+ slot: data
+ - suffix: Page
+ genericClass: org.springframework.data.domain.Page
+ slotArray: content
+ - suffix: ErrorResult
+ genericClass: Result
+ slots:
+ data: T
+ error: E
+ discoverGenericPatterns: "true"
diff --git a/bin/configs/spring-boot-generics.yaml b/bin/configs/spring-boot-generics.yaml
new file mode 100644
index 000000000000..ce22ceb1ef1a
--- /dev/null
+++ b/bin/configs/spring-boot-generics.yaml
@@ -0,0 +1,30 @@
+generatorName: spring
+outputDir: samples/server/petstore/springboot-generics
+library: spring-boot
+inputSpec: modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics.yaml
+templateDir: modules/openapi-generator/src/main/resources/JavaSpring
+additionalProperties:
+ documentationProvider: none
+ annotationLibrary: none
+ useSwaggerUI: "false"
+ serializableModel: "true"
+ useBeanValidation: "true"
+ interfaceOnly: "true"
+ skipDefaultInterface: "true"
+ useSpringBoot3: "true"
+ hideGenerationTimestamp: "true"
+ useTags: "true"
+ requestMappingMode: api_interface
+ genericPatterns:
+ - suffix: Response
+ genericClass: ApiResponse
+ slot: data
+ - suffix: Page
+ genericClass: org.springframework.data.domain.Page
+ slotArray: content
+ - suffix: ErrorResult
+ genericClass: Result
+ slots:
+ data: T
+ error: E
+ discoverGenericPatterns: "true"
diff --git a/docs/customization-genericPatterns.md b/docs/customization-genericPatterns.md
new file mode 100644
index 000000000000..cc3ece6abf46
--- /dev/null
+++ b/docs/customization-genericPatterns.md
@@ -0,0 +1,176 @@
+---
+id: customization-generic-patterns
+title: Generic Schema Substitution (Spring / Kotlin-Spring)
+---
+
+# Generic Schema Substitution (`genericPatterns`)
+
+> Applies to the `spring` and `kotlin-spring` generators.
+
+OpenAPI 3 has no native way to express *parametric* schemas — schemas that share a common shape but differ in one type. In practice many specs end up duplicating wrapper schemas:
+
+```yaml
+UserResponse:
+ type: object
+ properties:
+ data: { $ref: '#/components/schemas/User' }
+ requestId: { type: string }
+OrderResponse:
+ type: object
+ properties:
+ data: { $ref: '#/components/schemas/Order' }
+ requestId: { type: string }
+PetResponse:
+ type: object
+ properties:
+ data: { $ref: '#/components/schemas/Pet' }
+ requestId: { type: string }
+```
+
+Without this feature, the generator creates three nearly-identical `*Response` classes. With `genericPatterns`, the generator detects the family and replaces all references with a single `ApiResponse` (either imported from your codebase or generated for you), and the redundant wrapper schemas are removed from the generated model package.
+
+## Quick start
+
+```yaml
+# openapitools-generator-maven-plugin config (or YAML config file)
+additionalProperties:
+ genericPatterns:
+ - suffix: Response
+ genericClass: com.example.ApiResponse
+ slot: data
+```
+
+After generation, every operation that previously returned `UserResponse` now returns `ApiResponse`, properties of type `UserResponse` are rewritten too, and the three `*Response` model classes are no longer generated.
+
+> ⚠️ Substitution and suppression of wrapper schemas only happen when `annotationLibrary=none` (Swagger / OpenAPI annotations on generated models reference the concrete classes, so they must be kept). Return-type substitution itself happens regardless.
+
+## Pattern matching
+
+Each entry in `genericPatterns` matches schemas by *name*:
+
+| Field | Purpose | Required? |
+|---|---|---|
+| `suffix` | Schema name ends with this string (e.g. `Response` matches `UserResponse`) | exactly one of `suffix` or `prefix` |
+| `prefix` | Schema name starts with this string (e.g. `Api` matches `ApiUser`) | exactly one of `suffix` or `prefix` |
+| `genericClass` | Target generic class. Mode A (FQN) imports an external class; Mode B (simple name) generates a class in `configPackage`. | yes |
+| `slot` | Property name whose `$ref` becomes `T`. Single type parameter. | one of `slot` / `slotArray` / `slots` |
+| `slotArray` | Array property name whose `items.$ref` becomes `T`. Single type parameter. | one of `slot` / `slotArray` / `slots` |
+| `slots` | Map of `propertyName: typeParamName` for multi-parameter generics (e.g. `{data: T, error: E}`). | one of `slot` / `slotArray` / `slots` |
+
+## Mode A vs Mode B
+
+The form of `genericClass` decides whether a class file is generated:
+
+* **Mode A** — `genericClass` contains a dot (`.`). Treated as a fully-qualified class name; only an `importMapping` entry is added. **Use this when the class already exists** in your codebase or in a library:
+
+ ```yaml
+ - suffix: Response
+ genericClass: com.acme.api.ApiResponse # already exists in com.acme.api
+ slot: data
+ ```
+
+* **Mode B** — `genericClass` is a simple name (no dot). A new source file is generated in `configPackage` (defaults to `org.openapitools.configuration`). The generated class mirrors the non-slot properties of the matched schemas and declares the configured type parameters:
+
+ ```yaml
+ - suffix: Page
+ genericClass: ApiPage # creates ApiPage.java/kt with the common props +
+ slotArray: content
+ ```
+
+## Multi-slot generics
+
+Use `slots` (instead of `slot` / `slotArray`) to map multiple properties to multiple type parameters:
+
+```yaml
+genericPatterns:
+ - suffix: ErrorResult
+ genericClass: Result # generated class Result
+ slots:
+ data: T # 'data' property → T
+ error: E # 'error' property → E
+```
+
+A spec schema `UserValidationErrorResult` (with `data: $ref User`, `error: $ref ValidationError`) becomes `Result` everywhere it appears.
+
+Array-ness of each slot property is auto-detected from the matched schema — you do not need to declare it.
+
+## Vendor-extension overrides
+
+Patterns are a heuristic. If a single schema needs to be substituted differently (or excluded), declare the substitution inline:
+
+```yaml
+components:
+ schemas:
+ SearchPage:
+ x-generic:
+ class: org.springframework.data.domain.Slice
+ slot: content
+ type: object
+ properties:
+ content:
+ type: array
+ items: { $ref: '#/components/schemas/SearchResult' }
+ hasNext: { type: boolean }
+```
+
+Vendor-extension declarations take precedence over both name-pattern matches and structurally-detected paged models.
+
+## Discovery (`discoverGenericPatterns`)
+
+If you don't yet know which schemas in your spec are good candidates, enable:
+
+```yaml
+additionalProperties:
+ discoverGenericPatterns: true
+```
+
+During the next generation, the tool scans for **structural clusters** — groups of 2+ schemas with identical property structure except for one varying `$ref` property — and logs a ready-to-paste `genericPatterns:` YAML block at **INFO** level. No substitution is applied; it is purely a suggestion.
+
+> ℹ️ To see the suggestions, ensure your logging configuration emits INFO-level messages for `org.openapitools.codegen.languages.GenericSchemaScanUtils`. The Maven plugin shows them by default; CLI users may need `--verbose`.
+
+### What discovery does *not* find
+
+Discovery is intentionally limited to **single-slot** patterns. The slot itself may be either a plain `$ref` (suggested as `slot:`) or an `array` of `$ref` (suggested as `slotArray:`, which covers the typical `Page` shape: `{ content: array of $ref, page: $ref Metadata }`). Both flat-object and `allOf`-based schemas are scanned; for `allOf` shapes the extends-bases are part of the structural fingerprint, so members that extend different bases (e.g. `UserPage extends PageMeta` vs. `OrderPage extends CursorMeta`) will *not* be incorrectly clustered together. Discovery will **not** suggest:
+
+* **Multi-slot generics** (e.g. `Result`) — only single-slot families are auto-detected.
+* **`allOf` schemas with more than one inline-object entry** — ambiguous which entry owns the slot, so they are skipped.
+* **Schemas that don't share a common name suffix** — clustering also needs a stable naming convention to suggest a usable pattern.
+
+> **Note**: paged-`allOf` clusters (e.g. `UserPage` / `OrderPage` extending a shared `PageMeta`) *will* now show up as `slotArray:` suggestions. For pure pagination cases prefer [`substituteGenericPagedModel`](#companion-feature-substitutegenericpagedmodel) — it's auto-applied (no pattern config), structurally detects both flat and `allOf` paged shapes, and removes the orphaned metadata schemas in one go.
+
+For any of these, fall back to a hand-written `genericPatterns` entry or a `x-generic` vendor extension on the individual schema.
+
+## Companion feature: `substituteGenericPagedModel`
+
+A purely structural variant exists for the very common Spring `PagedModel` case:
+
+```yaml
+additionalProperties:
+ substituteGenericPagedModel: true
+```
+
+This requires *no* pattern config and *no* naming convention — the generator detects any schema with a `content` array property and a pagination-metadata `$ref` (e.g. a `PageMetadata`-style sibling), in both flat-object and `allOf` forms. The detected paged schemas are replaced with `PagedModel` and the orphaned metadata schemas are suppressed.
+
+Internally this routes through the same substitution engine as `genericPatterns`, so all the interactions described below apply.
+
+## Interaction with other options
+
+| Option | Effect |
+|---|---|
+| `schemaMapping` | A schema name present in `schemaMapping` is **never** substituted by `genericPatterns` (the user-declared mapping wins). The corresponding companion meta-schema is kept alive too if any sibling main is still mapped. |
+| `importMapping` | Mode A registers its FQN here automatically. For `substituteGenericPagedModel`, set `importMapping.PagedModel` (or any custom name) to override the default Spring class. |
+| `modelNameSuffix` / `modelNamePrefix` / `modelNameMapping` | Fully supported. Registry keys are re-keyed via `toModelName()` so lookups by the transformed name work. Two raw names collapsing to the same transformed name will emit a `WARN` log and only one substitution will apply. |
+| `annotationLibrary != none` | Return-type substitution still runs, but wrapper / meta schemas are kept (annotations like `@ApiResponse`, `@Schema` reference them by class). |
+
+## Examples in the repository
+
+* `bin/configs/spring-boot-generics.yaml` and `bin/configs/kotlin-spring-boot-generics.yaml` — runnable end-to-end configurations covering all three pattern forms plus discovery.
+* `samples/server/petstore/springboot-generics/` — generated output from the above config.
+* The input spec at `modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics.yaml` exercises single-slot, multi-slot, array-slot, vendor-extension, and paged-model variants together.
+
+## Limitations and notes
+
+* The features are currently implemented only for the `spring` and `kotlin-spring` generators.
+* Pattern matching is purely name-based — schemas that do not share a naming convention are not detected by tier-2 patterns (use vendor extensions on those schemas, or rely on `discoverGenericPatterns` / `substituteGenericPagedModel`).
+* Suppression of substituted schemas is gated on `annotationLibrary=none` (see above).
+* The data class powering this is documented in detail in [`GenericPatternConfig.java`](https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericPatternConfig.java).
diff --git a/docs/generators/java-camel.md b/docs/generators/java-camel.md
index b67c8e111a69..b3ad0322861e 100644
--- a/docs/generators/java-camel.md
+++ b/docs/generators/java-camel.md
@@ -56,6 +56,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|disableDiscriminatorJsonIgnoreProperties|Ignore discriminator field type for Jackson serialization| |false|
|disableHtmlEscaping|Disable HTML escaping of JSON strings when using gson (needed to avoid problems with byte[] fields)| |false|
|disallowAdditionalPropertiesIfNotPresent|If false, the 'additionalProperties' implementation (set to true by default) is compliant with the OAS and JSON schema specifications. If true (default), keep the old (incorrect) behaviour that 'additionalProperties' is set to false by default.|
**false**
The 'additionalProperties' implementation is compliant with the OAS and JSON schema specifications.
**true**
Keep the old (incorrect) behaviour that 'additionalProperties' is set to false by default.
|true|
+|discoverGenericPatterns|When true, scans schemas for structural clusters (groups of schemas with the same structure except for one varying $ref property) and logs them as INFO-level suggestions for configuring genericPatterns. Never auto-applies substitution.| |false|
|discriminatorCaseSensitive|Whether the discriminator value lookup should be case-sensitive or not. This option only works for Java API client| |true|
|documentationProvider|Select the OpenAPI documentation provider.|
**none**
Do not publish an OpenAPI specification.
**source**
Publish the original input OpenAPI specification.
**springdoc**
Generate an OpenAPI 3 specification using SpringDoc.
|springdoc|
|ensureUniqueParams|Whether to ensure parameter names are unique in an operation (rename parameters that are not).| |true|
@@ -67,6 +68,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|generatePageableConstraintValidation|Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to the injected Pageable parameter of operations whose 'page' or 'size' parameter specifies a maximum constraint. The annotation enforces those constraints on the Pageable object that replaces the individual page/size query parameters. Requires useBeanValidation=true and library=spring-boot.| |false|
|generateSortValidation|Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to the injected Pageable parameter of operations whose 'sort' parameter has enum values. The annotation validates that sort values in the Pageable object match the allowed enum values from the spec. Requires useBeanValidation=true and library=spring-boot.| |false|
|generatedConstructorWithRequiredArgs|Whether to generate constructors with required args for models| |true|
+|genericPatterns|List of generic substitution patterns. Each entry specifies a suffix or prefix to match schema names against, a target generic class (FQN for import-only Mode A, or simple name for generated Mode B), and the slot or slotArray property that becomes the type parameter T. Example (YAML config): genericPatterns: [{suffix: Response, genericClass: ApiResponse, slot: data}]. See GenericPatternConfig for full documentation.| |null|
|groupId|groupId in generated pom.xml| |org.openapitools|
|hateoas|Use Spring HATEOAS library to allow adding HATEOAS links| |false|
|hideGenerationTimestamp|Hides the generation timestamp when files are generated.| |false|
@@ -112,6 +114,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|useBeanValidation|Use BeanValidation API annotations| |true|
|useDeductionForOneOfInterfaces|Annotate discriminator-free oneOf interfaces with Jackson's @JsonTypeInfo(use = Id.DEDUCTION) and @JsonSubTypes so the concrete subtype is resolved from the JSON field set rather than a type-tag property. Has no effect when a discriminator is present (name-based resolution is used instead). Requires subtypes to have structurally distinct sets of properties.| |false|
|useEnumCaseInsensitive|Use `equalsIgnoreCase` when String for enum comparison| |false|
+|useEnumValueInterface|Generate a ValuedEnum<T> interface in the config package and make all generated enums implement it, providing a common typed way to access the underlying enum value. Use `importMappings.ValuedEnum` to substitute a custom/library-provided interface instead of generating one.| |false|
|useFeignClientContextId|Whether to generate Feign client with contextId parameter.| |true|
|useFeignClientUrl|Whether to generate Feign client with url parameter.| |true|
|useHttpServiceProxyFactoryInterfacesConfigurator|Generate HttpInterfacesAbstractConfigurator based on an HttpServiceProxyFactory instance (as opposed to a WebClient instance, when disabled) for generating Spring HTTP interfaces.| |false|
@@ -145,7 +148,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|x-class-extra-annotation|List of custom annotations to be added to model|MODEL|null
|x-field-extra-annotation|List of custom annotations to be added to property|FIELD, OPERATION_PARAMETER|null
|x-operation-extra-annotation|List of custom annotations to be added to operation|OPERATION|null
-|x-spring-paginated|Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object.|OPERATION|false
+|x-spring-paginated|Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object. Only applies when `library=spring-boot`; ignored for client libraries (spring-cloud, spring-declarative-http-interface).|OPERATION|false
|x-version-param|Marker property that tells that this parameter would be used for endpoint versioning. Applicable for headers & query params. true/false|OPERATION_PARAMETER|null
|x-pattern-message|Add this property whenever you need to customize the invalidation error message for the regex pattern of a variable|FIELD, OPERATION_PARAMETER|null
|x-size-message|Add this property whenever you need to customize the invalidation error message for the size or length of a variable|FIELD, OPERATION_PARAMETER|null
diff --git a/docs/generators/kotlin-spring.md b/docs/generators/kotlin-spring.md
index 1687486e809d..5031b818066c 100644
--- a/docs/generators/kotlin-spring.md
+++ b/docs/generators/kotlin-spring.md
@@ -31,11 +31,13 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|configPackage|configuration package for generated code| |org.openapitools.configuration|
|declarativeInterfaceReactiveMode|What type of reactive style to use in Spring Http declarative interface|
**coroutines**
Use kotlin-idiomatic 'suspend' functions
**reactor**
Use reactor return wrappers 'Mono' and 'Flux'
|coroutines|
|delegatePattern|Whether to generate the server files using the delegate pattern| |false|
+|discoverGenericPatterns|When true, scans schemas for structural clusters and logs them as INFO-level suggestions for configuring genericPatterns.| |false|
|documentationProvider|Select the OpenAPI documentation provider.|
**none**
Do not publish an OpenAPI specification.
**source**
Publish the original input OpenAPI specification.
**springdoc**
Generate an OpenAPI 3 specification using SpringDoc.
|springdoc|
|enumPropertyNaming|Naming convention for enum properties: 'camelCase', 'PascalCase', 'snake_case', 'UPPERCASE', 'original', and 'bestEffortBacktick' (like 'original' but tries to wrap values in backticks before falling back to sanitizing, e.g. `name,asc` stays `name,asc` rather than becoming nameCommaAsc; useful for sort/order enums)| |original|
|exceptionHandler|generate default global exception handlers (not compatible with reactive. enabling reactive will disable exceptionHandler )| |true|
|generatePageableConstraintValidation|Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to the injected Pageable parameter of operations whose 'page' or 'size' parameter specifies a maximum constraint. The annotation enforces those constraints on the Pageable object that replaces the individual page/size query parameters. Requires useBeanValidation=true and library=spring-boot.| |false|
|generateSortValidation|Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to the injected Pageable parameter of operations whose 'sort' parameter has enum values. The annotation validates that sort values in the Pageable object match the allowed enum values from the spec. Requires useBeanValidation=true and library=spring-boot.| |false|
+|genericPatterns|List of generic substitution patterns. Each entry specifies a suffix or prefix to match schema names against, a target generic class (FQN for import-only Mode A, or simple name for generated Mode B), and the slot or slotArray property that becomes the type parameter T.| |null|
|gradleBuildFile|generate a gradle build file using the Kotlin DSL| |true|
|groupId|Generated artifact package's organization (i.e. maven groupId).| |org.openapitools|
|implicitHeaders|Skip header parameters in the generated API methods.| |false|
@@ -64,6 +66,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|title|server title name or client service name| |OpenAPI Kotlin Spring|
|useBeanValidation|Use BeanValidation API annotations to validate data types| |true|
|useDeductionForOneOfInterfaces|Annotate discriminator-free oneOf interfaces with Jackson's @JsonTypeInfo(use = Id.DEDUCTION) and @JsonSubTypes so the concrete subtype is resolved from the JSON field set rather than a type-tag property. Has no effect when a discriminator is present (name-based resolution is used instead). Requires subtypes to have structurally distinct sets of properties.| |false|
+|useEnumValueInterface|Generate a ValuedEnum<T> interface in the config package and make all generated enums implement it, providing a common typed way to access the underlying enum value. Use `importMappings.ValuedEnum` to substitute a custom/library-provided interface instead of generating one.| |false|
|useFeignClientUrl|Whether to generate Feign client with url parameter.| |true|
|useFlowForArrayReturnType|Whether to use Flow for array/collection return types when reactive is enabled. If false, will use List instead.| |true|
|useJackson3|Use Jackson 3 dependencies (tools.jackson package). Only available with `useSpringBoot4`. Defaults to true when `useSpringBoot4` is enabled.| |false|
@@ -92,7 +95,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|x-maximum-message|Add this property whenever you need to customize the invalidation error message for the maximum value of a variable|FIELD, OPERATION_PARAMETER|null
|x-kotlin-implements|Ability to specify interfaces that model must implement|MODEL|empty array
|x-kotlin-implements-fields|Specify attributes that are implemented by the interface(s) added via `x-kotlin-implements`|MODEL|empty array
-|x-spring-paginated|Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object.|OPERATION|false
+|x-spring-paginated|Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object. Only applies when `library=spring-boot`; ignored for client libraries (spring-cloud, spring-declarative-http-interface).|OPERATION|false
## IMPORT MAPPING
diff --git a/docs/generators/spring.md b/docs/generators/spring.md
index 1cda6152be9f..ca30fdd23023 100644
--- a/docs/generators/spring.md
+++ b/docs/generators/spring.md
@@ -49,6 +49,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|disableDiscriminatorJsonIgnoreProperties|Ignore discriminator field type for Jackson serialization| |false|
|disableHtmlEscaping|Disable HTML escaping of JSON strings when using gson (needed to avoid problems with byte[] fields)| |false|
|disallowAdditionalPropertiesIfNotPresent|If false, the 'additionalProperties' implementation (set to true by default) is compliant with the OAS and JSON schema specifications. If true (default), keep the old (incorrect) behaviour that 'additionalProperties' is set to false by default.|
**false**
The 'additionalProperties' implementation is compliant with the OAS and JSON schema specifications.
**true**
Keep the old (incorrect) behaviour that 'additionalProperties' is set to false by default.
|true|
+|discoverGenericPatterns|When true, scans schemas for structural clusters (groups of schemas with the same structure except for one varying $ref property) and logs them as INFO-level suggestions for configuring genericPatterns. Never auto-applies substitution.| |false|
|discriminatorCaseSensitive|Whether the discriminator value lookup should be case-sensitive or not. This option only works for Java API client| |true|
|documentationProvider|Select the OpenAPI documentation provider.|
**none**
Do not publish an OpenAPI specification.
**source**
Publish the original input OpenAPI specification.
**springdoc**
Generate an OpenAPI 3 specification using SpringDoc.
|springdoc|
|ensureUniqueParams|Whether to ensure parameter names are unique in an operation (rename parameters that are not).| |true|
@@ -60,6 +61,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|generatePageableConstraintValidation|Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to the injected Pageable parameter of operations whose 'page' or 'size' parameter specifies a maximum constraint. The annotation enforces those constraints on the Pageable object that replaces the individual page/size query parameters. Requires useBeanValidation=true and library=spring-boot.| |false|
|generateSortValidation|Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to the injected Pageable parameter of operations whose 'sort' parameter has enum values. The annotation validates that sort values in the Pageable object match the allowed enum values from the spec. Requires useBeanValidation=true and library=spring-boot.| |false|
|generatedConstructorWithRequiredArgs|Whether to generate constructors with required args for models| |true|
+|genericPatterns|List of generic substitution patterns. Each entry specifies a suffix or prefix to match schema names against, a target generic class (FQN for import-only Mode A, or simple name for generated Mode B), and the slot or slotArray property that becomes the type parameter T. Example (YAML config): genericPatterns: [{suffix: Response, genericClass: ApiResponse, slot: data}]. See GenericPatternConfig for full documentation.| |null|
|groupId|groupId in generated pom.xml| |org.openapitools|
|hateoas|Use Spring HATEOAS library to allow adding HATEOAS links| |false|
|hideGenerationTimestamp|Hides the generation timestamp when files are generated.| |false|
@@ -105,6 +107,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|useBeanValidation|Use BeanValidation API annotations| |true|
|useDeductionForOneOfInterfaces|Annotate discriminator-free oneOf interfaces with Jackson's @JsonTypeInfo(use = Id.DEDUCTION) and @JsonSubTypes so the concrete subtype is resolved from the JSON field set rather than a type-tag property. Has no effect when a discriminator is present (name-based resolution is used instead). Requires subtypes to have structurally distinct sets of properties.| |false|
|useEnumCaseInsensitive|Use `equalsIgnoreCase` when String for enum comparison| |false|
+|useEnumValueInterface|Generate a ValuedEnum<T> interface in the config package and make all generated enums implement it, providing a common typed way to access the underlying enum value. Use `importMappings.ValuedEnum` to substitute a custom/library-provided interface instead of generating one.| |false|
|useFeignClientContextId|Whether to generate Feign client with contextId parameter.| |true|
|useFeignClientUrl|Whether to generate Feign client with url parameter.| |true|
|useHttpServiceProxyFactoryInterfacesConfigurator|Generate HttpInterfacesAbstractConfigurator based on an HttpServiceProxyFactory instance (as opposed to a WebClient instance, when disabled) for generating Spring HTTP interfaces.| |false|
@@ -138,7 +141,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|x-class-extra-annotation|List of custom annotations to be added to model|MODEL|null
|x-field-extra-annotation|List of custom annotations to be added to property|FIELD, OPERATION_PARAMETER|null
|x-operation-extra-annotation|List of custom annotations to be added to operation|OPERATION|null
-|x-spring-paginated|Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object.|OPERATION|false
+|x-spring-paginated|Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object. Only applies when `library=spring-boot`; ignored for client libraries (spring-cloud, spring-declarative-http-interface).|OPERATION|false
|x-version-param|Marker property that tells that this parameter would be used for endpoint versioning. Applicable for headers & query params. true/false|OPERATION_PARAMETER|null
|x-pattern-message|Add this property whenever you need to customize the invalidation error message for the regex pattern of a variable|FIELD, OPERATION_PARAMETER|null
|x-size-message|Add this property whenever you need to customize the invalidation error message for the size or length of a variable|FIELD, OPERATION_PARAMETER|null
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 27cd5b4ab0bd..aa48fe4b27fb 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
@@ -224,6 +224,16 @@ public interface CodegenConfig {
Map postProcessSupportingFileData(Map objs);
+ /**
+ * Called immediately before each supporting file is rendered.
+ * Generators may override this to mutate {@code bundle} with per-file data.
+ * The default implementation is a no-op.
+ *
+ * @param bundle the shared data bundle passed to the template engine
+ * @param support the supporting file about to be rendered
+ */
+ default void prepareSupportingFile(Map bundle, SupportingFile support) {}
+
void postProcessModelProperty(CodegenModel model, CodegenProperty property);
void postProcessResponseWithProperty(CodegenResponse response, CodegenProperty property);
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 713bc8f7bf9f..19fd2113a837 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
@@ -492,6 +492,13 @@ public static enum ENUM_PROPERTY_NAMING_TYPE {camelCase, PascalCase, snake_case,
public static final String X_MODEL_IS_MUTABLE = "x-model-is-mutable";
public static final String X_IMPLEMENTS = "x-implements";
public static final String X_IS_ONE_OF_INTERFACE = "x-is-one-of-interface";
+ public static final String USE_ENUM_VALUE_INTERFACE = "useEnumValueInterface";
+ public static final String USE_ENUM_VALUE_INTERFACE_DESC =
+ "Generate a ValuedEnum interface in the config package and make all generated enums " +
+ "implement it, providing a common typed way to access the underlying enum value. " +
+ "Use `importMappings.ValuedEnum` to substitute a custom/library-provided interface " +
+ "instead of generating one.";
+
public static final String USE_DEDUCTION_FOR_ONE_OF_INTERFACES = "useDeductionForOneOfInterfaces";
public static final String USE_DEDUCTION_FOR_ONE_OF_INTERFACES_DESC =
"Annotate discriminator-free oneOf interfaces with Jackson's " +
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 d000cde76814..7996cbc1fac8 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
@@ -1039,6 +1039,11 @@ public Map postProcessSupportingFileData(Map obj
return objs;
}
+ @Override
+ public void prepareSupportingFile(Map bundle, SupportingFile file) {
+ // default no-op; override in generators that need per-file bundle data
+ }
+
// override to post-process any model properties
@Override
@SuppressWarnings("unused")
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 60b17e8e47d5..195db9784579 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
@@ -1106,6 +1106,12 @@ private void generateSupportingFiles(List files, Map bundl
shouldGenerate = supportingFilesToGenerate.contains(support.getDestinationFilename());
}
+ // Only let the codegen mutate the shared bundle when this file will actually
+ // be rendered. Otherwise per-file injections (e.g. genericClassDef from
+ // GenericSubstitutionSupport) would leak into the next iteration's render.
+ if (shouldGenerate && ignoreProcessor.allowsFile(new File(outputFilename))) {
+ config.prepareSupportingFile(bundle, support);
+ }
File written = processTemplateToFile(bundle, support.getTemplateFile(), outputFilename, shouldGenerate, CodegenConstants.SUPPORTING_FILES);
if (written != null) {
files.add(written);
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java
index bce0e2a691f4..9be773475f2c 100644
--- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java
@@ -12,7 +12,7 @@ public enum VendorExtension {
X_IMPLEMENTS("x-implements", ExtensionLevel.MODEL, "Ability to specify interfaces that model must implements", "empty array"),
X_KOTLIN_IMPLEMENTS("x-kotlin-implements", ExtensionLevel.MODEL, "Ability to specify interfaces that model must implement", "empty array"),
X_KOTLIN_IMPLEMENTS_FIELDS("x-kotlin-implements-fields", ExtensionLevel.MODEL, "Specify attributes that are implemented by the interface(s) added via `x-kotlin-implements`", "empty array"),
- X_SPRING_PAGINATED("x-spring-paginated", ExtensionLevel.OPERATION, "Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object.", "false"),
+ X_SPRING_PAGINATED("x-spring-paginated", ExtensionLevel.OPERATION, "Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object. Only applies when `library=spring-boot`; ignored for client libraries (spring-cloud, spring-declarative-http-interface).", "false"),
X_SPRING_API_VERSION("x-spring-api-version", ExtensionLevel.OPERATION, "Value for 'version' attribute in @RequestMapping (for Spring 7 and above).", null),
X_SPRING_PROVIDE_ARGS("x-spring-provide-args", ExtensionLevel.OPERATION, "Allows adding additional hidden parameters in the API specification to allow access to content such as header values or properties", "empty array"),
X_DISCRIMINATOR_VALUE("x-discriminator-value", ExtensionLevel.MODEL, "Used with model inheritance to specify value for discriminator that identifies current model", ""),
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/EnumValueInterfaceUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/EnumValueInterfaceUtils.java
new file mode 100644
index 000000000000..60f9ff8e9a4d
--- /dev/null
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/EnumValueInterfaceUtils.java
@@ -0,0 +1,134 @@
+package org.openapitools.codegen.languages;
+
+import org.openapitools.codegen.CodegenModel;
+import org.openapitools.codegen.CodegenProperty;
+import org.openapitools.codegen.DefaultCodegen;
+import org.openapitools.codegen.SupportingFile;
+import org.openapitools.codegen.model.ModelMap;
+import org.openapitools.codegen.model.ModelsMap;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Language-agnostic utility for the {@code useEnumValueInterface} code-generation option.
+ *
+ *
When enabled, every generated enum (both top-level schema enums and inline property enums)
+ * is made to implement a common {@code ValuedEnum} interface that exposes the backing value,
+ * allowing generic code to access enum values without reflection.
+ *
+ *
Two entry points are provided, one for each generator lifecycle hook:
+ *
+ *
{@link #setupInPreprocessOpenAPI} — called from {@code preprocessOpenAPI} to register
+ * the supporting file and the import mapping.
+ *
{@link #injectInPostProcessModelsEnum} — called from {@code postProcessModelsEnum} to
+ * inject the interface into every enum model's implements vendor extension.
+ *
+ *
+ *
The only language-specific knobs are:
+ *
+ *
The name of the vendor extension that carries implemented interfaces
+ * ({@code "x-implements"} for Java, {@code "x-kotlin-implements"} for Kotlin).
+ *
The output file name ({@code "ValuedEnum.java"} vs {@code "ValuedEnum.kt"}).
+ *
+ *
+ *
Used by both {@link SpringCodegen} and {@link KotlinSpringServerCodegen}.
+ */
+public final class EnumValueInterfaceUtils {
+
+ private EnumValueInterfaceUtils() {}
+
+ /**
+ * Registers the {@code ValuedEnum} supporting file and import mapping.
+ *
+ *
Must be called from {@code preprocessOpenAPI} when {@code useEnumValueInterface} is
+ * enabled. Returns the simple class name derived from the (possibly custom) import mapping,
+ * which the caller must store and pass to {@link #injectInPostProcessModelsEnum} later.
+ *
+ * @param importMapping the codegen's import-mapping map (mutated in place)
+ * @param additionalProperties the codegen's additional-properties map (mutated in place)
+ * @param supportingFiles the codegen's supporting-files list (mutated in place)
+ * @param sourceFolder language source folder (e.g. {@code "src/main/java"})
+ * @param configPackage the config package where the interface is generated
+ * (e.g. {@code "org.openapitools.configuration"})
+ * @param mustacheTemplate template name (e.g. {@code "enumValueInterface.mustache"})
+ * @param outputFileName generated file name (e.g. {@code "ValuedEnum.java"})
+ * @return the simple class name of {@code ValuedEnum} (accounts for custom import mappings)
+ */
+ public static String setupInPreprocessOpenAPI(
+ Map importMapping,
+ Map additionalProperties,
+ List supportingFiles,
+ String sourceFolder,
+ String configPackage,
+ String mustacheTemplate,
+ String outputFileName) {
+
+ boolean customMapping = importMapping.containsKey("ValuedEnum");
+ importMapping.putIfAbsent("ValuedEnum", configPackage + ".ValuedEnum");
+ if (!customMapping) {
+ supportingFiles.add(new SupportingFile(mustacheTemplate,
+ (sourceFolder + File.separator + configPackage).replace(".", File.separator),
+ outputFileName));
+ }
+ String fqn = importMapping.get("ValuedEnum");
+ String className = fqn.substring(fqn.lastIndexOf('.') + 1);
+ additionalProperties.put("useEnumValueInterface", true);
+ return className;
+ }
+
+ /**
+ * Injects {@code ValuedEnum} into the implements vendor extension of every enum in the
+ * given model batch.
+ *
+ *
Must be called from {@code postProcessModelsEnum} when {@code useEnumValueInterface} is
+ * enabled. Handles both top-level enum schemas and inline enum properties.
+ *
+ * @param objs the model batch being post-processed
+ * @param valuedEnumClassName simple class name (e.g. {@code "ValuedEnum"})
+ * @param valuedEnumFqn fully-qualified name used for the import statement
+ * @param xImplementsExtensionKey vendor-extension key that carries the implements list
+ * ({@code "x-implements"} for Java,
+ * {@code "x-kotlin-implements"} for Kotlin)
+ */
+ public static void injectInPostProcessModelsEnum(
+ ModelsMap objs,
+ String valuedEnumClassName,
+ String valuedEnumFqn,
+ String xImplementsExtensionKey) {
+
+ List
+ *
+ *
Schemas already matched by Tier 1 (i.e. in {@code tier1SchemaNames}) are skipped.
+ *
+ * @param openAPI the parsed OpenAPI document
+ * @param patterns list of patterns to match against
+ * @param tier1SchemaNames schema names already handled by Tier 1 (excluded from matching)
+ * @return list of detected instances; empty if none found or no patterns provided
+ */
+ public static List scanWithPatterns(OpenAPI openAPI,
+ List patterns,
+ Set tier1SchemaNames) {
+ List result = new ArrayList<>();
+ if (openAPI.getComponents() == null || openAPI.getComponents().getSchemas() == null
+ || patterns == null || patterns.isEmpty()) {
+ return result;
+ }
+
+ for (Map.Entry schemaEntry : openAPI.getComponents().getSchemas().entrySet()) {
+ String schemaName = schemaEntry.getKey();
+ if (tier1SchemaNames.contains(schemaName)) {
+ continue;
+ }
+ Schema> schema = schemaEntry.getValue();
+
+ for (GenericPatternConfig pattern : patterns) {
+ if (pattern.genericClass == null || pattern.genericClass.isEmpty()) {
+ logger().warn("GenericSchemaScanUtils Tier2: pattern has no genericClass — skipping: {}",
+ pattern);
+ continue;
+ }
+ if (!matchesPattern(schemaName, pattern)) {
+ continue;
+ }
+
+ // Determine slots: use pattern.slots if set, else normalize slot/slotArray
+ Map effectiveSlots = null;
+ if (pattern.slots != null && !pattern.slots.isEmpty()) {
+ effectiveSlots = pattern.slots;
+ } else if (pattern.slot != null && !pattern.slot.isEmpty()) {
+ effectiveSlots = Collections.singletonMap(pattern.slot, "T");
+ } else if (pattern.slotArray != null && !pattern.slotArray.isEmpty()) {
+ effectiveSlots = Collections.singletonMap(pattern.slotArray, "T");
+ }
+
+ if (effectiveSlots == null) {
+ logger().warn("GenericSchemaScanUtils Tier2: pattern has no slot/slotArray/slots — skipping: {}",
+ pattern);
+ continue;
+ }
+
+ Map props = resolveProperties(schema, openAPI);
+
+ // Resolve each configured slot to its type arg schema name
+ Map typeArgs = new LinkedHashMap<>();
+ Map slotTypeParams = new LinkedHashMap<>();
+ String primarySlotName = null;
+ boolean primarySlotIsArray = false;
+ boolean allSlotsFound = true;
+
+ for (Map.Entry slotEntry : effectiveSlots.entrySet()) {
+ String slotPropName = slotEntry.getKey();
+ String typeParamName = slotEntry.getValue();
+
+ // Try $ref slot first
+ String ref = findRefInProperties(props, slotPropName);
+ if (ref != null) {
+ typeArgs.put(slotPropName, extractSchemaNameFromRef(ref));
+ slotTypeParams.put(slotPropName, typeParamName);
+ if (primarySlotName == null) {
+ primarySlotName = slotPropName;
+ primarySlotIsArray = false;
+ }
+ continue;
+ }
+
+ // Try array slot
+ String arrayRef = findArrayItemRefInProperties(props, schema, slotPropName, openAPI);
+ if (arrayRef != null) {
+ typeArgs.put(slotPropName, extractSchemaNameFromRef(arrayRef));
+ slotTypeParams.put(slotPropName, typeParamName);
+ if (primarySlotName == null) {
+ primarySlotName = slotPropName;
+ primarySlotIsArray = true;
+ }
+ continue;
+ }
+
+ logger().debug("GenericSchemaScanUtils Tier2: schema '{}' matched pattern '{}' by name "
+ + "but slot '{}' not found or not a $ref — skipping",
+ schemaName, pattern, slotPropName);
+ allSlotsFound = false;
+ break;
+ }
+
+ if (!allSlotsFound || primarySlotName == null) {
+ continue;
+ }
+
+ boolean isFqn = pattern.genericClass.contains(".");
+ String genericClassName = isFqn
+ ? pattern.genericClass.substring(pattern.genericClass.lastIndexOf('.') + 1)
+ : pattern.genericClass;
+
+ List properties = buildProperties(schema, openAPI, slotTypeParams);
+
+ result.add(new GenericInstance(
+ schemaName, genericClassName,
+ isFqn ? pattern.genericClass : null,
+ !isFqn,
+ typeArgs, slotTypeParams, primarySlotName, primarySlotIsArray, properties));
+
+ logger().debug("GenericSchemaScanUtils Tier2: schema '{}' matched pattern '{}' → {}<{}>",
+ schemaName, pattern.suffix != null ? ("suffix=" + pattern.suffix) : ("prefix=" + pattern.prefix),
+ genericClassName, String.join(", ", typeArgs.values()));
+ break; // first matching pattern wins
+ }
+ }
+ return result;
+ }
+
+ // =========================================================================
+ // Tier 3 — Structural cluster discovery
+ // =========================================================================
+
+ /**
+ * Scans all named schemas looking for structural clusters: groups of 2 or more schemas
+ * that have the same property names and types except for exactly one varying property
+ * (either a plain {@code $ref} or an {@code array} of {@code $ref}) whose target $ref
+ * differs across members.
+ *
+ *
This is discovery-only: no substitution is performed. The suggestions are
+ * returned (and typically logged by the caller) to help the user configure Tier 2
+ * patterns.
+ *
+ * @param openAPI the parsed OpenAPI document
+ * @param excludedSchemaNames schema names to exclude (e.g. already handled by Tier 1/2)
+ * @return list of cluster suggestions; empty if none found
+ */
+ public static List discoverClusters(OpenAPI openAPI,
+ Set excludedSchemaNames) {
+ List result = new ArrayList<>();
+ if (openAPI.getComponents() == null || openAPI.getComponents().getSchemas() == null) {
+ return result;
+ }
+
+ Map allSchemas = openAPI.getComponents().getSchemas();
+
+ // Pre-resolve each candidate schema's property map once. For flat-object schemas this
+ // is the direct properties; for allOf schemas it's the single inline-object entry's
+ // properties (or null if 0 / >1 inline entries — those are out of scope).
+ Map> candidatePropsByName = new LinkedHashMap<>();
+ Map> byFingerprint = new LinkedHashMap<>();
+ for (Map.Entry entry : allSchemas.entrySet()) {
+ String name = entry.getKey();
+ if (excludedSchemaNames.contains(name)) {
+ continue;
+ }
+ Schema> schema = entry.getValue();
+ String fp = buildStructuralFingerprint(schema);
+ if (fp == null) {
+ continue; // no resolvable properties or out-of-scope allOf shape
+ }
+ Map props = getCandidateProperties(schema);
+ if (props == null) {
+ continue; // shouldn't happen — fingerprint already null-guarded this case
+ }
+ candidatePropsByName.put(name, props);
+ byFingerprint.computeIfAbsent(fp, k -> new ArrayList<>()).add(name);
+ }
+
+ // For each group of 2+, look for the varying $ref / array[$ref] property
+ for (Map.Entry> fpEntry : byFingerprint.entrySet()) {
+ List names = fpEntry.getValue();
+ if (names.size() < 2) {
+ continue;
+ }
+
+ // Find the property whose $ref target differs across all members
+ VaryingProperty varying = findVaryingRefProperty(names, candidatePropsByName);
+ if (varying == null) {
+ continue;
+ }
+
+ // Collect all varying types
+ List varyingTypes = new ArrayList<>();
+ for (String name : names) {
+ Map props = candidatePropsByName.get(name);
+ Schema> prop = props.get(varying.name);
+ String ref = extractVaryingRef(prop, varying.isArray);
+ if (ref != null) {
+ varyingTypes.add(extractSchemaNameFromRef(ref));
+ }
+ }
+
+ // Determine the most likely common suffix for the suggestion
+ String suggestedSuffix = commonSuffix(names);
+ String suggestedConfig = buildSuggestedConfig(suggestedSuffix, varying.name,
+ varying.isArray, names);
+
+ result.add(new ClusterSuggestion(new ArrayList<>(names), varying.name,
+ varying.isArray, varyingTypes, suggestedConfig));
+ }
+ return result;
+ }
+
+ // =========================================================================
+ // Private helpers — pattern matching
+ // =========================================================================
+
+ static boolean matchesPattern(String schemaName, GenericPatternConfig pattern) {
+ if (pattern.suffix != null && !pattern.suffix.isEmpty()) {
+ return schemaName.endsWith(pattern.suffix) && schemaName.length() > pattern.suffix.length();
+ }
+ if (pattern.prefix != null && !pattern.prefix.isEmpty()) {
+ return schemaName.startsWith(pattern.prefix) && schemaName.length() > pattern.prefix.length();
+ }
+ return false;
+ }
+
+ // =========================================================================
+ // Private helpers — property resolution
+ // =========================================================================
+
+ /**
+ * Returns the merged properties of a schema, handling both flat-object and allOf forms.
+ * Returns {@code null} if the schema has no resolvable properties.
+ */
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ static Map resolveProperties(Schema> schema, OpenAPI openAPI) {
+ if (schema.getProperties() != null && !schema.getProperties().isEmpty()) {
+ return (Map) schema.getProperties();
+ }
+ // Handle allOf: merge inline object properties from allOf entries
+ if (schema.getAllOf() != null) {
+ Map merged = new LinkedHashMap<>();
+ for (Object entryObj : schema.getAllOf()) {
+ if (!(entryObj instanceof Schema)) continue;
+ Schema entry = (Schema) entryObj;
+ if (entry.getProperties() != null) {
+ merged.putAll(entry.getProperties());
+ }
+ }
+ return merged.isEmpty() ? null : merged;
+ }
+ return null;
+ }
+
+ /**
+ * Returns the {@code $ref} string of a property in the resolved properties.
+ * Returns {@code null} if the property is absent or is not declared as a direct
+ * {@code $ref} (inline-composed properties are intentionally not followed).
+ */
+ @SuppressWarnings("rawtypes")
+ private static String findRefInProperties(Map props, String propName) {
+ if (props == null) return null;
+ Schema> prop = (Schema>) props.get(propName);
+ if (prop == null) return null;
+ return prop.get$ref();
+ }
+
+ /**
+ * Finds the {@code $ref} of array items for the given property name.
+ * Searches both the top-level (flat-object) and allOf inline objects.
+ */
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private static String findArrayItemRefInProperties(Map props, Schema> schema,
+ String slotName, OpenAPI openAPI) {
+ // Top-level properties
+ if (props != null) {
+ Schema> slotProp = (Schema>) props.get(slotName);
+ if (slotProp != null) {
+ String ref = extractArrayItemRef(slotProp);
+ if (ref != null) return ref;
+ }
+ }
+ // allOf inline entries
+ if (schema.getAllOf() != null) {
+ for (Object entryObj : schema.getAllOf()) {
+ if (!(entryObj instanceof Schema)) continue;
+ Schema entry = (Schema) entryObj;
+ if (entry.getProperties() == null) continue;
+ Schema> slotProp = (Schema>) entry.getProperties().get(slotName);
+ if (slotProp != null) {
+ String ref = extractArrayItemRef(slotProp);
+ if (ref != null) return ref;
+ }
+ }
+ }
+ return null;
+ }
+
+ @SuppressWarnings("rawtypes")
+ private static String extractArrayItemRef(Schema> schema) {
+ if (schema == null) return null;
+ if (!"array".equals(schema.getType())) return null;
+ Schema items = schema.getItems();
+ return items != null ? items.get$ref() : null;
+ }
+
+ // =========================================================================
+ // Private helpers — property list building for class generation
+ // =========================================================================
+
+ /**
+ * Builds the full property list for a matched schema, marking slot properties with
+ * their respective type parameters from {@code slotTypeParams}.
+ * Array-ness is auto-detected from each property schema type.
+ */
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private static List buildProperties(Schema> schema, OpenAPI openAPI,
+ Map slotTypeParams) {
+ List result = new ArrayList<>();
+ Set required = schema.getRequired() != null
+ ? new HashSet<>(schema.getRequired()) : Collections.emptySet();
+
+ Map props = resolveProperties(schema, openAPI);
+ if (props == null) return result;
+
+ for (Map.Entry entry : props.entrySet()) {
+ String name = entry.getKey();
+ Schema> propSchema = (Schema>) entry.getValue();
+ boolean isRequired = required.contains(name);
+
+ String typeParam = slotTypeParams.get(name);
+ if (typeParam != null) {
+ // Slot property — auto-detect array-ness
+ boolean isSlotArray = "array".equals(propSchema.getType());
+ String format = isSlotArray && propSchema.getItems() != null
+ ? propSchema.getItems().getFormat() : propSchema.getFormat();
+ result.add(new GenericProperty(name, isSlotArray ? "array" : "$ref",
+ null, typeParam, format, isSlotArray, isRequired));
+ } else {
+ result.add(buildNonSlotProperty(name, propSchema, isRequired, openAPI));
+ }
+ }
+ return result;
+ }
+
+ @SuppressWarnings("rawtypes")
+ private static GenericProperty buildNonSlotProperty(String name, Schema> propSchema,
+ boolean required, OpenAPI openAPI) {
+ if (propSchema.get$ref() != null) {
+ String target = extractSchemaNameFromRef(propSchema.get$ref());
+ return new GenericProperty(name, "$ref", target, null,
+ propSchema.getFormat(), false, required);
+ }
+ if ("array".equals(propSchema.getType())) {
+ String itemRef = null;
+ if (propSchema.getItems() != null && propSchema.getItems().get$ref() != null) {
+ itemRef = extractSchemaNameFromRef(propSchema.getItems().get$ref());
+ }
+ String itemType = itemRef != null ? itemRef
+ : (propSchema.getItems() != null ? propSchema.getItems().getType() : "Object");
+ return new GenericProperty(name, "array", itemType, null,
+ propSchema.getFormat(), true, required);
+ }
+ String type = propSchema.getType() != null ? propSchema.getType() : "object";
+ return new GenericProperty(name, type, null, null, propSchema.getFormat(), false, required);
+ }
+
+ // =========================================================================
+ // Private helpers — structural fingerprinting (Tier 3)
+ // =========================================================================
+
+ /**
+ * Returns the property map that Tier-3 clustering should fingerprint and inspect for
+ * a varying slot.
+ *
+ *
+ *
Flat-object schema → its direct {@code getProperties()}.
+ *
allOf schema with exactly one inline-object entry → that entry's properties
+ * (the {@code $ref} entries are encoded separately as "extends-bases").
+ *
allOf schema with more than one inline-object entry → {@code null}
+ * (out of scope; ambiguous which entry owns the slot).
+ *
Anything else → {@code null}.
+ *
+ */
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private static Map getCandidateProperties(Schema> schema) {
+ if (schema.getAllOf() != null && !schema.getAllOf().isEmpty()) {
+ Map inlineProps = null;
+ int inlineCount = 0;
+ for (Object entryObj : schema.getAllOf()) {
+ if (!(entryObj instanceof Schema)) continue;
+ Schema entry = (Schema) entryObj;
+ if (entry.get$ref() != null) continue; // extends-base — accounted for in fingerprint
+ if (entry.getProperties() == null || entry.getProperties().isEmpty()) continue;
+ inlineCount++;
+ if (inlineCount > 1) return null; // ambiguous, out of scope
+ inlineProps = (Map) entry.getProperties();
+ }
+ return inlineProps;
+ }
+ if (schema.getProperties() == null || schema.getProperties().isEmpty()) {
+ return null;
+ }
+ return (Map) schema.getProperties();
+ }
+
+ /**
+ * Returns a sorted, comma-separated list of {@code $ref} target names appearing as
+ * extends-bases in this schema's {@code allOf}. Empty string for non-allOf schemas
+ * or allOf schemas without any {@code $ref} entries.
+ */
+ private static String getAllOfExtendsFingerprint(Schema> schema) {
+ if (schema.getAllOf() == null || schema.getAllOf().isEmpty()) return "";
+ List refs = new ArrayList<>();
+ for (Object entryObj : schema.getAllOf()) {
+ if (!(entryObj instanceof Schema)) continue;
+ Schema> entry = (Schema>) entryObj;
+ String ref = entry.get$ref();
+ if (ref != null) {
+ refs.add(extractSchemaNameFromRef(ref));
+ }
+ }
+ Collections.sort(refs);
+ return String.join(",", refs);
+ }
+
+ /**
+ * Builds a canonical structural fingerprint for a schema.
+ *
+ *
For flat-object schemas the fingerprint encodes each property as
+ * {@code "name:typeDescriptor"} where the type descriptor for {@code $ref} properties
+ * is just {@code "$ref"} (ignoring the target). This allows grouping of structurally
+ * identical schemas that differ only in their {@code $ref} targets.
+ *
+ *
For allOf schemas (with at most one inline-object entry) the fingerprint includes
+ * the sorted list of {@code $ref} extends-bases AND the inline entry's property
+ * fingerprint. This keeps allOf members that extend different bases from clustering
+ * together (e.g. {@code UserPage extends PageMeta} vs {@code OrderPage extends CursorMeta}).
+ *
+ *
Returns {@code null} if the schema cannot be fingerprinted (no resolvable properties,
+ * or more than one inline-object entry in allOf).
+ */
+ static String buildStructuralFingerprint(Schema> schema) {
+ Map props = getCandidateProperties(schema);
+ if (props == null || props.isEmpty()) {
+ return null;
+ }
+ String propsFp = props.entrySet().stream()
+ .sorted(Map.Entry.comparingByKey())
+ .map(e -> e.getKey() + ":" + propertyTypeDescriptor(e.getValue()))
+ .collect(Collectors.joining("|"));
+ if (schema.getAllOf() != null && !schema.getAllOf().isEmpty()) {
+ // Prefix with shape marker + extends-bases so allOf and flat never collide and
+ // so allOf members with different bases get different fingerprints.
+ return "allOf|extends:" + getAllOfExtendsFingerprint(schema) + "|inline:" + propsFp;
+ }
+ return "flat|" + propsFp;
+ }
+
+ @SuppressWarnings("rawtypes")
+ private static String propertyTypeDescriptor(Schema> prop) {
+ if (prop == null) return "null";
+ if (prop.get$ref() != null) return "$ref";
+ if ("array".equals(prop.getType())) {
+ if (prop.getItems() != null) {
+ if (prop.getItems().get$ref() != null) return "array[$ref]";
+ return "array[" + prop.getItems().getType() + "]";
+ }
+ return "array[?]";
+ }
+ return prop.getType() != null ? prop.getType() : "object";
+ }
+
+ /** Result of {@link #findVaryingRefProperty(List, Map)}: the candidate slot name + slot shape. */
+ private static final class VaryingProperty {
+ final String name;
+ final boolean isArray;
+ VaryingProperty(String name, boolean isArray) { this.name = name; this.isArray = isArray; }
+ }
+
+ /**
+ * Returns the {@code $ref} of a candidate slot property — either {@code prop.get$ref()}
+ * (plain ref) or {@code prop.getItems().get$ref()} (array of ref), depending on {@code isArray}.
+ * Returns {@code null} if the property doesn't carry the expected ref shape.
+ */
+ @SuppressWarnings("rawtypes")
+ private static String extractVaryingRef(Schema> prop, boolean isArray) {
+ if (prop == null) return null;
+ if (isArray) {
+ Schema> items = prop.getItems();
+ return items != null ? items.get$ref() : null;
+ }
+ return prop.get$ref();
+ }
+
+ /**
+ * Finds the name of the property whose {@code $ref} target varies across all schemas
+ * in the group (all other {@code $ref} / {@code array[$ref]} properties must be identical).
+ * Returns {@code null} if no such unique varying property exists.
+ *
+ *
Both plain {@code $ref} and {@code array} of {@code $ref} properties are considered;
+ * the result flags which shape was found so the caller can emit {@code slot:} vs
+ * {@code slotArray:} suggestions accordingly.
+ *
+ *
{@code candidatePropsByName} pre-resolves each member to its property map
+ * (direct properties for flat-object schemas, single inline-allOf-entry properties for
+ * allOf schemas) so the same logic serves both shapes.
+ */
+ @SuppressWarnings("rawtypes")
+ private static VaryingProperty findVaryingRefProperty(List schemaNames,
+ Map> candidatePropsByName) {
+ if (schemaNames.isEmpty()) return null;
+ Map firstProps = candidatePropsByName.get(schemaNames.get(0));
+ if (firstProps == null) return null;
+
+ // Collect candidate properties (plain $ref OR array[$ref]) from the first member
+ List candidates = new ArrayList<>();
+ for (Map.Entry entry : firstProps.entrySet()) {
+ Schema> prop = entry.getValue();
+ if (prop.get$ref() != null) {
+ candidates.add(new VaryingProperty(entry.getKey(), false));
+ } else if ("array".equals(prop.getType()) && prop.getItems() != null
+ && prop.getItems().get$ref() != null) {
+ candidates.add(new VaryingProperty(entry.getKey(), true));
+ }
+ }
+
+ VaryingProperty varying = null;
+ for (VaryingProperty candidate : candidates) {
+ Set refs = new HashSet<>();
+ boolean abort = false;
+ for (String name : schemaNames) {
+ Map props = candidatePropsByName.get(name);
+ if (props == null) { abort = true; break; }
+ Schema> prop = props.get(candidate.name);
+ String ref = extractVaryingRef(prop, candidate.isArray);
+ if (ref == null) { abort = true; break; }
+ refs.add(ref);
+ }
+ if (abort) continue;
+ if (refs.size() == schemaNames.size()) {
+ // Every member has this property as a ref of the expected shape and all are distinct
+ if (varying != null) {
+ return null; // more than one varying property — not a simple generic
+ }
+ varying = candidate;
+ }
+ }
+ return varying;
+ }
+
+ private static String commonSuffix(List names) {
+ if (names.isEmpty()) return "";
+ String first = names.get(0);
+ for (int len = 1; len <= first.length(); len++) {
+ String suffix = first.substring(first.length() - len);
+ boolean allMatch = names.stream().allMatch(n ->
+ n.endsWith(suffix) && n.length() > suffix.length());
+ if (!allMatch) {
+ return first.substring(first.length() - len + 1);
+ }
+ }
+ // Unreachable: the predicate fails at len == first.length() because the first
+ // name itself doesn't satisfy n.length() > suffix.length().
+ return "";
+ }
+
+ private static String buildSuggestedConfig(String suggestedSuffix, String slotProperty,
+ boolean isArraySlot, List schemaNames) {
+ String slotKey = isArraySlot ? "slotArray" : "slot";
+ return "genericPatterns:\n"
+ + " - suffix: " + suggestedSuffix + "\n"
+ + " genericClass: \n"
+ + " " + slotKey + ": " + slotProperty + "\n"
+ + " # Schemas matched: " + String.join(", ", schemaNames);
+ }
+
+ // =========================================================================
+ // Package-level utility — used by GenericSubstitutionSupport and PagedModelScanUtils
+ // =========================================================================
+
+ /**
+ * Extracts the simple schema name from a {@code $ref} string such as
+ * {@code #/components/schemas/User} → {@code User}, or
+ * {@code ./external.yaml#/components/schemas/Order} → {@code Order}.
+ */
+ static String extractSchemaNameFromRef(String ref) {
+ if (ref == null) return null;
+ int slash = ref.lastIndexOf('/');
+ return slash >= 0 ? ref.substring(slash + 1) : ref;
+ }
+}
diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericSubstitutionSupport.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericSubstitutionSupport.java
new file mode 100644
index 000000000000..7b4681cf83a2
--- /dev/null
+++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericSubstitutionSupport.java
@@ -0,0 +1,918 @@
+/*
+ * Copyright 2018 OpenAPI-Generator Contributors (https://openapi-generator.tech)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.openapitools.codegen.languages;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import org.openapitools.codegen.CodegenModel;
+import org.openapitools.codegen.CodegenOperation;
+import org.openapitools.codegen.CodegenProperty;
+import org.openapitools.codegen.SupportingFile;
+import org.openapitools.codegen.languages.features.DocumentationProviderFeatures.AnnotationLibrary;
+import org.openapitools.codegen.model.ModelMap;
+import org.openapitools.codegen.model.ModelsMap;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.util.*;
+
+/**
+ * Stateful delegate that centralises all generic-schema substitution logic usable by
+ * any Spring or other typed generator.
+ *
+ *
This class is the runtime counterpart to {@link GenericSchemaScanUtils}: the scanner
+ * is pure/stateless; this class holds the scan results, drives file generation, and hooks
+ * into the three code-generation lifecycle phases.
+ *
+ *
Usage
+ *
+ *
Create one instance per generator run and store it as a field.
+ *
Call {@link #addPattern} in {@code processOpts()} for each configured pattern.
+ *
Call {@link #preprocessOpenAPI} from the generator's {@code preprocessOpenAPI} override.
+ *
Call {@link #substituteReturnType} from the generator's {@code fromOperation} override.
+ *
Call {@link #suppressGenericSchemas} from {@code postProcessAllModels}.
+ *
+ *
+ *
Mode A vs Mode B
+ *
+ *
Mode A ({@code genericClass} is a FQN): only an import-mapping entry is
+ * added; no source file is generated.
+ *
Mode B ({@code genericClass} is a simple name): a {@code SupportingFile}
+ * entry is registered during {@code preprocessOpenAPI} using the
+ * {@code genericClass.mustache} template. The {@link #prepareSupportingFile} hook
+ * injects per-class bundle data so each class renders with its own properties.
+ * Generators that use this class must override
+ * {@link org.openapitools.codegen.CodegenConfig#prepareSupportingFile} and
+ * delegate to this method.
+ *
+ *
+ *
Relationship to {@link SpringPageableSupport}
+ *
This class is the single substitution / suppression code path for both
+ * name-based generic patterns (this class' own {@code genericPatterns} config) and
+ * structurally-detected paged-model schemas (contributed by
+ * {@link SpringPageableSupport} when {@code substituteGenericPagedModel} is enabled):
+ *
+ *
{@code genericPatterns} uses name-based pattern matching (suffix / prefix /
+ * vendor extensions). It can target any generic class with any number of type parameters
+ * ({@code slots}), but relies on schemas following a naming convention.
+ *
{@code substituteGenericPagedModel} uses structural detection via
+ * {@link PagedModelScanUtils}. {@link SpringPageableSupport#contributeToGenericSubstitution}
+ * converts each detected paged model into a {@link GenericSchemaScanUtils.GenericInstance}
+ * and registers it here via {@link #addPreScannedInstance}, along with the raw name of
+ * the companion metadata schema (e.g. {@code PageMetadata}) so that schema can be
+ * suppressed alongside the main schema when no longer referenced.
+ *
+ *
+ *
Precedence inside {@link #preprocessOpenAPI}: vendor-extension (tier 1) overrides
+ * pre-scanned pageable, which in turn overrides configured patterns (tier 2). Suppression of
+ * companion meta-schemas (e.g. {@code PageMetadata}) is gated on every associated main schema
+ * having been successfully suppressed in the same pass, so a {@code schemaMapping}-protected
+ * sibling will keep its meta-schema alive.
+ */
+public final class GenericSubstitutionSupport {
+
+ private final Logger LOGGER = LoggerFactory.getLogger(GenericSubstitutionSupport.class);
+
+ // =========================================================================
+ // Context interface
+ // =========================================================================
+
+ /**
+ * Narrow callback interface that gives {@link GenericSubstitutionSupport} read/write access
+ * to the generator's configuration without requiring a specific base class.
+ */
+ public interface Context {
+ /** Returns the config package, e.g. {@code "org.openapitools.configuration"}. */
+ String getConfigPackage();
+
+ /** Returns the source folder path (e.g. {@code "src/main/java"}). */
+ String getSourceFolder();
+
+ /**
+ * Returns the active annotation library.
+ */
+ AnnotationLibrary getAnnotationLibrary();
+
+ /** Converts an unqualified schema name to a codegen model name. */
+ String toModelName(String name);
+
+ /**
+ * Returns the generator's mutable {@code importMapping} map.
+ * Callers may add entries directly.
+ */
+ Map importMapping();
+
+ /**
+ * Returns the generator's mutable {@code supportingFiles} list.
+ * Mode B classes are registered here as {@link SupportingFile} entries during
+ * {@link #preprocessOpenAPI}.
+ */
+ List supportingFiles();
+
+ /**
+ * Returns the file extension for generated source files, without the leading dot.
+ * {@code "java"} for Java; {@code "kt"} for Kotlin.
+ */
+ String fileExtension();
+
+ /**
+ * Converts a simple class name to a fully-qualified model import path.
+ * Typically returns {@code modelPackage() + "." + className}.
+ * Used to build FQN entries when syncing {@code ModelsMap.imports} after
+ * property-type substitution.
+ */
+ String toModelImport(String className);
+
+ /**
+ * Returns the generator's schema mapping (schema name → external class name/FQN).
+ * Used to skip generic substitution for schemas that are explicitly schema-mapped,
+ * since the user's intent is to use an external class, not generate or substitute it.
+ */
+ Map schemaMapping();
+ }
+
+ // =========================================================================
+ // Configuration and state
+ // =========================================================================
+
+ private final List patterns = new ArrayList<>();
+ private boolean discoverGenericPatterns = false;
+
+ /**
+ * Map from concrete schema name (e.g. {@code "UserResponse"}) to its detected
+ * {@link GenericSchemaScanUtils.GenericInstance}. Populated during
+ * {@link #preprocessOpenAPI} and consumed in later lifecycle phases.
+ */
+ private final Map instanceRegistry =
+ new LinkedHashMap<>();
+
+ /**
+ * Maps raw companion meta-schema name to the set of raw main schema names that
+ * reference it, contributed by structural-detection delegates via
+ * {@link #addPreScannedInstance}. E.g. {@code "PageMetadata" → {"UserPage", "OrderPage"}}
+ * when both paged models share one metadata schema.
+ *
A meta-schema is suppressed in {@link #suppressGenericSchemas} only when all
+ * associated main schemas were actually removed in the same pass, so a
+ * {@code schemaMapping}-protected sibling keeps its meta-schema alive.
+ */
+ private final Map> extraSuppressedMetaSchemas = new LinkedHashMap<>();
+
+ /**
+ * Bundle data for each Mode B class, keyed by simple class name (e.g. {@code "ApiResponse"}).
+ * Built during {@link #preprocessOpenAPI} and injected into the template bundle by
+ * {@link #prepareSupportingFile}.
+ */
+ private final Map> modeBBundleData = new LinkedHashMap<>();
+
+ // =========================================================================
+ // Configuration setters
+ // =========================================================================
+
+ public void addPattern(GenericPatternConfig cfg) {
+ patterns.add(cfg);
+ }
+
+ public void setDiscoverGenericPatterns(boolean v) {
+ this.discoverGenericPatterns = v;
+ }
+
+ /**
+ * Adds a pre-scanned generic instance contributed by a structural-detection delegate
+ * (e.g. {@link SpringPageableSupport} for paged-model schemas).
+ *
+ *
Pre-scanned instances are added to the registry before
+ * {@link #preprocessOpenAPI} scans tier-1 (vendor extensions) and tier-2 (configured
+ * patterns). Tier-1 vendor-extension declarations therefore take precedence over
+ * pre-scanned instances (overwrite them). Tier-2 pattern scanning skips schemas already
+ * present in the registry (via the {@code tier1Names} exclusion set).
+ *
+ *
Call this from the structural-detection delegate's
+ * {@code contributeToGenericSubstitution} method, before calling
+ * {@link #preprocessOpenAPI}.
+ *
+ * @param inst the detected generic instance; {@code inst.schemaName} must be
+ * the raw OpenAPI schema name (re-keying via {@code toModelName()}
+ * is performed automatically in {@link #preprocessOpenAPI})
+ * @param rawMetaSchemaName raw OpenAPI name of a companion schema to suppress alongside
+ * the main schema (e.g. {@code "PageMetadata"}), or {@code null}
+ */
+ public void addPreScannedInstance(GenericSchemaScanUtils.GenericInstance inst,
+ String rawMetaSchemaName) {
+ instanceRegistry.put(inst.schemaName, inst);
+ if (rawMetaSchemaName != null) {
+ extraSuppressedMetaSchemas
+ .computeIfAbsent(rawMetaSchemaName, k -> new LinkedHashSet<>())
+ .add(inst.schemaName);
+ }
+ }
+
+ // =========================================================================
+ // Lifecycle 1: preprocessOpenAPI
+ // =========================================================================
+
+ /**
+ * Scans the OpenAPI spec for generic patterns, registers import mappings, and (for
+ * Mode B) writes generated class source files to the output folder.
+ *
+ *
Call this from the generator's {@code preprocessOpenAPI} override, after
+ * calling {@code super.preprocessOpenAPI(openAPI)}.
+ */
+ public void preprocessOpenAPI(OpenAPI openAPI, Context ctx) {
+ if (openAPI == null) return;
+
+ // --- Tier 1: vendor extensions ---
+ List tier1 =
+ GenericSchemaScanUtils.scanVendorExtensions(openAPI);
+ for (GenericSchemaScanUtils.GenericInstance inst : tier1) {
+ instanceRegistry.put(inst.schemaName, inst);
+ }
+
+ // --- Tier 2: configured patterns ---
+ if (!patterns.isEmpty()) {
+ Set tier1Names = new HashSet<>(instanceRegistry.keySet());
+ List tier2 =
+ GenericSchemaScanUtils.scanWithPatterns(openAPI, patterns, tier1Names);
+ for (GenericSchemaScanUtils.GenericInstance inst : tier2) {
+ instanceRegistry.put(inst.schemaName, inst);
+ }
+ }
+
+ // --- Tier 3: discovery (logging only) ---
+ if (discoverGenericPatterns) {
+ Set alreadyHandled = new HashSet<>(instanceRegistry.keySet());
+ List suggestions =
+ GenericSchemaScanUtils.discoverClusters(openAPI, alreadyHandled);
+ for (GenericSchemaScanUtils.ClusterSuggestion suggestion : suggestions) {
+ LOGGER.info("[discoverGenericPatterns] Potential generic pattern detected:\n"
+ + " Schemas: {}\n"
+ + " Varying slot: '{}' → {}\n"
+ + " Suggested config:\n {}",
+ String.join(", ", suggestion.schemaNames),
+ suggestion.varyingSlotProperty,
+ suggestion.varyingTypes,
+ suggestion.suggestedConfig.replace("\n", "\n "));
+ }
+ }
+
+ if (instanceRegistry.isEmpty()) {
+ return;
+ }
+
+ // Re-key the registry by applying toModelName() to every raw spec schema name.
+ // This ensures that lookups by op.returnBaseType (already transformed) and
+ // objs keys (also transformed) succeed when modelNameSuffix / modelNamePrefix /
+ // schemaMapping / modelNameMapping are active.
+ Map reKeyed = new LinkedHashMap<>();
+ for (Map.Entry entry : instanceRegistry.entrySet()) {
+ String transformedKey = ctx.toModelName(entry.getKey());
+ GenericSchemaScanUtils.GenericInstance previous = reKeyed.put(transformedKey, entry.getValue());
+ if (previous != null) {
+ LOGGER.warn("GenericSubstitutionSupport: schema names '{}' and '{}' both map to "
+ + "the transformed model name '{}' (via toModelName / nameMapping / "
+ + "modelNameMapping). Only '{}' will be substituted; '{}' will be ignored.",
+ previous.schemaName, entry.getValue().schemaName,
+ transformedKey, entry.getValue().schemaName, previous.schemaName);
+ }
+ }
+ instanceRegistry.clear();
+ instanceRegistry.putAll(reKeyed);
+
+ // Gap B: filter out instances whose raw spec schema name is in schemaMapping.
+ // When a user has schemaMapping: { UserResponse: com.example.UserResponse } they intend
+ // to replace the schema with an external class — generic substitution must not override that.
+ Map schemaMappings = ctx.schemaMapping();
+ if (!schemaMappings.isEmpty()) {
+ instanceRegistry.entrySet().removeIf(entry -> {
+ GenericSchemaScanUtils.GenericInstance inst = entry.getValue();
+ if (schemaMappings.containsKey(inst.schemaName)) {
+ LOGGER.warn("GenericSubstitutionSupport: skipping generic instance '{}' — " +
+ "its raw schema name '{}' is present in schemaMapping; " +
+ "schemaMapping takes precedence over genericPatterns.",
+ entry.getKey(), inst.schemaName);
+ return true;
+ }
+ return false;
+ });
+ }
+
+ LOGGER.info("GenericSubstitutionSupport: detected {} generic schema instance(s): {}",
+ instanceRegistry.size(), instanceRegistry.keySet());
+
+ // --- Register imports and (Mode B) generate class files ---
+ String ext = ctx.fileExtension();
+ String configPath = (ctx.getSourceFolder() + File.separator + ctx.getConfigPackage())
+ .replace(".", File.separator);
+
+ // Track which generic class names we've already processed (Mode A or Mode B)
+ Map processedGenericClasses = new LinkedHashMap<>();
+
+ for (GenericSchemaScanUtils.GenericInstance inst : instanceRegistry.values()) {
+ String className = inst.genericClassName;
+
+ if (processedGenericClasses.containsKey(className)) {
+ // Already registered this generic class
+ continue;
+ }
+ processedGenericClasses.put(className, inst);
+
+ if (inst.generateClass) {
+ // Mode B: register a mustache-based supporting file and add import mapping
+ String fqn = ctx.getConfigPackage() + "." + className;
+
+ // Gap A: warn if there is a pre-existing importMapping entry that points elsewhere.
+ // putIfAbsent below will keep the user's mapping, but the Mode B file is still
+ // generated at configPackage — the two will be out of sync.
+ String existingMapping = ctx.importMapping().get(className);
+ if (existingMapping != null && !existingMapping.equals(fqn)) {
+ LOGGER.warn("GenericSubstitutionSupport: Mode B class '{}' conflicts with " +
+ "a pre-existing importMapping entry: importMapping maps '{}' → '{}', " +
+ "but the generated file will be at '{}.{}'. " +
+ "Generated imports will reference '{}' while the file lives elsewhere. " +
+ "Consider removing the conflicting importMapping entry.",
+ className, className, existingMapping, configPath, ext, existingMapping);
+ }
+ ctx.importMapping().putIfAbsent(className, fqn);
+
+ if (!modeBBundleData.containsKey(className)) {
+ modeBBundleData.put(className, buildBundleData(inst));
+ ctx.supportingFiles().add(new SupportingFile("genericClass.mustache",
+ configPath, className + "." + ext));
+ LOGGER.info("GenericSubstitutionSupport: registered Mode B '{}' → {}.{}",
+ className, configPath, ext);
+ }
+ } else {
+ // Mode A: FQN provided — add to importMapping only
+ ctx.importMapping().putIfAbsent(className, inst.genericClassFqn);
+ LOGGER.info("GenericSubstitutionSupport: Mode A class '{}' → importMapping: {}",
+ className, inst.genericClassFqn);
+ }
+ }
+ }
+
+ // =========================================================================
+ // Lifecycle 2: substituteReturnType (called from fromOperation)
+ // =========================================================================
+
+ /**
+ * Replaces the operation's return type with the generic form when the return base type
+ * matches a detected generic schema instance.
+ *
+ *
Example: operation returning {@code UserResponse} becomes {@code ApiResponse}.
+ * If a type argument is itself a generic instance (e.g. {@code UserResponse} inside
+ * {@code Page}), the expansion is applied recursively:
+ * {@code UserResponsePage → Page>}.
+ *
+ *
Call this from {@code fromOperation} after calling
+ * {@code super.fromOperation(…)}.
+ */
+ public void substituteReturnType(CodegenOperation op, Context ctx) {
+ if (instanceRegistry.isEmpty() || op.returnBaseType == null) {
+ return;
+ }
+ GenericSchemaScanUtils.GenericInstance inst = instanceRegistry.get(op.returnBaseType);
+ if (inst == null) {
+ return;
+ }
+
+ String oldType = op.returnType;
+ String expansion = buildGenericTypeName(inst, ctx, new HashSet<>());
+ String newType;
+ if (op.returnContainer != null && oldType != null) {
+ // Preserve the outer container — e.g. List → List>.
+ // The matched base type appears as the inner type token inside the container.
+ newType = oldType.replace(op.returnBaseType, expansion);
+ } else {
+ newType = expansion;
+ op.returnContainer = null; // generic wrapper is not a container
+ }
+
+ op.returnType = newType;
+ op.returnBaseType = inst.genericClassName;
+
+ collectImportsToAdd(inst, ctx, op.imports, new HashSet<>());
+ if (ctx.getAnnotationLibrary() == AnnotationLibrary.NONE) {
+ // Remove wrapper schema imports (recursively: any nested generic instance is also suppressed).
+ // op.imports holds toModelName()-processed names, matching the registry keys.
+ Set toRemove = new LinkedHashSet<>();
+ collectSuppressedImports(inst, ctx, toRemove, new HashSet<>());
+ op.imports.removeAll(toRemove);
+ }
+
+ LOGGER.info("GenericSubstitutionSupport: operation '{}': replacing return type '{}' with '{}'",
+ op.operationId, oldType, newType);
+ }
+
+ // =========================================================================
+ // Lifecycle 3: suppressGenericSchemas (called from postProcessAllModels)
+ // =========================================================================
+
+ /**
+ * Substitutes generic-instance schema references in model properties and then removes
+ * concrete generic-instance schemas (e.g. {@code UserResponse}, {@code PetResponse})
+ * from the model map when {@code annotationLibrary=none}.
+ *
+ *
Property substitution is performed first so that any model referencing a
+ * suppressed wrapper schema (e.g. {@code OrderDetails.userResult: UserResponse})
+ * has its property type replaced ({@code ApiResponse}) before the wrapper
+ * class is removed. This prevents compile errors in the generated code.
+ *
+ *
A safety check prevents suppression when another model still references the
+ * wrapper schema as a parent class ({@code extends UserResponse}) or via an
+ * unsubstituted property, logging a warning in that case.
+ *
+ *
When annotation libraries are active, {@code @ApiResponse} and {@code @Schema}
+ * annotations in the generated code reference concrete schema classes, so they must
+ * be kept (neither property substitution nor suppression is performed).
+ *
+ * @param objs model map as received by {@code postProcessAllModels}
+ * @param ctx callback access to the generator's state
+ * @return the (possibly mutated) model map
+ */
+ public Map suppressGenericSchemas(Map objs, Context ctx) {
+ if (instanceRegistry.isEmpty() && extraSuppressedMetaSchemas.isEmpty()) {
+ return objs;
+ }
+ if (ctx.getAnnotationLibrary() != AnnotationLibrary.NONE) {
+ LOGGER.info("GenericSubstitutionSupport: keeping generic-instance schemas "
+ + "(annotationLibrary={}) — @ApiResponse annotations reference them",
+ ctx.getAnnotationLibrary().toCliOptValue());
+ return objs;
+ }
+
+ substitutePropertyTypes(objs, ctx);
+
+ // Track which raw schema names were actually removed (gates meta-schema suppression below).
+ Set suppressedRawNames = new HashSet<>();
+
+ for (Map.Entry entry
+ : instanceRegistry.entrySet()) {
+ GenericSchemaScanUtils.GenericInstance inst = entry.getValue();
+ String transformedKey = entry.getKey(); // toModelName()-processed registry key
+
+ // Safety check: skip suppression if any model still references this schema
+ // via model.parent (inheritance) or an unsubstituted property baseType.
+ if (isStillReferenced(transformedKey, objs)) {
+ LOGGER.warn("GenericSubstitutionSupport: NOT suppressing '{}' — still referenced "
+ + "by another model (inheritance or unsubstituted property). "
+ + "The concrete class will be kept in the output.",
+ inst.schemaName);
+ continue;
+ }
+
+ // objs is keyed by the raw OpenAPI schema name (DefaultGenerator uses spec keys as-is).
+ // inst.schemaName is the raw spec name (toModelName() only affects the registry key).
+ if (objs.remove(inst.schemaName) != null) {
+ suppressedRawNames.add(inst.schemaName);
+ LOGGER.info("GenericSubstitutionSupport: suppressing model '{}' → {}",
+ inst.schemaName, buildGenericTypeName(inst, ctx, new HashSet<>()));
+ }
+ }
+
+ // Suppress companion meta-schemas contributed by pre-scan delegates (e.g.
+ // substituteGenericPagedModel). A meta-schema is only suppressed when ALL of its
+ // associated main schemas were actually removed in the loop above — if any sibling
+ // (e.g. one protected by schemaMapping) is still present, the meta-schema stays.
+ for (Map.Entry> metaEntry : extraSuppressedMetaSchemas.entrySet()) {
+ String rawMeta = metaEntry.getKey();
+ Set rawMains = metaEntry.getValue();
+ if (!suppressedRawNames.containsAll(rawMains)) {
+ // At least one associated main was kept — keep the meta schema too.
+ continue;
+ }
+ String transformedMeta = ctx.toModelName(rawMeta);
+ if (isStillReferenced(transformedMeta, objs)) {
+ LOGGER.info("GenericSubstitutionSupport: keeping companion meta-schema '{}'"
+ + " — still referenced by remaining models", rawMeta);
+ } else if (objs.remove(rawMeta) != null) {
+ LOGGER.info("GenericSubstitutionSupport: suppressing companion meta-schema '{}'"
+ + " — no longer referenced after main schema suppression", rawMeta);
+ }
+ }
+ return objs;
+ }
+
+ // =========================================================================
+ // prepareSupportingFile — per-file bundle injection
+ // =========================================================================
+
+ /**
+ * Injects per-file data into the shared template bundle before each Mode B supporting
+ * file is rendered.
+ *
+ *
Call this from the generator's {@code prepareSupportingFile} override.
+ *
+ * @param bundle the shared data bundle; will have {@code "genericClassDef"} added for Mode B files
+ * @param support the supporting file about to be rendered
+ */
+ public void prepareSupportingFile(Map bundle, SupportingFile support) {
+ if (!"genericClass.mustache".equals(support.getTemplateFile())) {
+ return;
+ }
+ String dest = support.getDestinationFilename();
+ int dot = dest.lastIndexOf('.');
+ if (dot < 0) return;
+ String className = dest.substring(0, dot);
+ // Always write the key — using null clears any stale value from a previous Mode B
+ // file render. Mustache's section helpers treat null as falsey so the template's
+ // {{#genericClassDef}}…{{/genericClassDef}} block is correctly skipped.
+ bundle.put("genericClassDef", modeBBundleData.get(className));
+ }
+
+ // =========================================================================
+ // Mode B template bundle data
+ // =========================================================================
+
+ /**
+ * Builds the data map injected into the bundle for a Mode B class template.
+ * Contains {@code className}, {@code needsList}, and a {@code properties} list.
+ */
+ private Map buildBundleData(GenericSchemaScanUtils.GenericInstance instance) {
+ Map data = new LinkedHashMap<>();
+ data.put("className", instance.genericClassName);
+
+ boolean needsList = instance.properties.stream().anyMatch(p -> p.isArray);
+ data.put("needsList", needsList ? Boolean.TRUE : null);
+
+ // Build ordered typeParams list for template class declaration: [{typeParam:"T",isLast:true}, ...]
+ List distinctTypeParams = new ArrayList<>(new LinkedHashSet<>(instance.slotTypeParams.values()));
+ List