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> imports = objs.getImports(); + for (ModelMap mo : objs.getModels()) { + CodegenModel cm = mo.getModel(); + boolean needsImport = false; + + if (cm.isEnum && cm.allowableValues != null) { + List xImpl = new ArrayList<>( + DefaultCodegen.getObjectAsStringList(cm.getVendorExtensions().get(xImplementsExtensionKey))); + xImpl.add(valuedEnumClassName + "<" + cm.dataType + ">"); + cm.getVendorExtensions().put(xImplementsExtensionKey, xImpl); + needsImport = true; + } + + for (CodegenProperty var : cm.vars) { + if (var.isEnum && !var.isContainer) { + List xVarImpl = new ArrayList<>( + DefaultCodegen.getObjectAsStringList(var.getVendorExtensions().get(xImplementsExtensionKey))); + xVarImpl.add(valuedEnumClassName + "<" + var.dataType + ">"); + var.getVendorExtensions().put(xImplementsExtensionKey, xVarImpl); + needsImport = true; + } + } + + if (needsImport) { + cm.imports.add(valuedEnumFqn); + Map importItem = new HashMap<>(); + importItem.put("import", valuedEnumFqn); + imports.add(importItem); + } + } + } +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericPatternConfig.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericPatternConfig.java new file mode 100644 index 000000000000..ff87372ffa2b --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericPatternConfig.java @@ -0,0 +1,129 @@ +/* + * 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 java.util.Map; + +/** + * Configuration for a single generic-class substitution pattern (Tier 2 detection). + * + *

A pattern matches schemas whose name ends with {@link #suffix} or starts with + * {@link #prefix}. Exactly one of {@code suffix} or {@code prefix} must be non-null.

+ * + *

Type-parameter slots are identified via {@link #slots} (multi-param) or the legacy + * convenience fields {@link #slot} (single {@code $ref}) and {@link #slotArray} (single + * array-of-{@code $ref}). When {@link #slots} is provided it takes precedence.

+ * + *

Example YAML config (single type param)

+ *
{@code
+ * additionalProperties:
+ *   genericPatterns:
+ *     - suffix: Response
+ *       genericClass: com.example.ApiResponse   # Mode A: FQN — import only, no file generated
+ *       slot: data                              # 'data' property is T
+ *     - suffix: Page
+ *       genericClass: ApiPage                  # Mode B: simple name — generate class in configPackage
+ *       slotArray: content                     # 'content' array property is List
+ * }
+ * + *

Example YAML config (two type params)

+ *
{@code
+ * additionalProperties:
+ *   genericPatterns:
+ *     - suffix: ErrorResult
+ *       genericClass: Result
+ *       slots:
+ *         data: T    # 'data' property becomes type param T
+ *         error: E   # 'error' property becomes type param E
+ * }
+ * + *

Mode A vs Mode B

+ *
    + *
  • Mode A: {@code genericClass} contains a dot ({@code .}) — treated as a + * fully-qualified class name. Only an import mapping entry is added; no file is + * generated. Use this when the generic class already exists (e.g. a Spring or library + * type).
  • + *
  • Mode B: {@code genericClass} is a simple name (no dot). A new source file + * ({@code .java} or {@code .kt}) is generated in the + * {@code configPackage} folder. The generated class mirrors the non-slot properties + * of the matched schemas and declares all configured type parameters.
  • + *
+ */ +public class GenericPatternConfig { + + /** + * Schema name suffix to match (e.g. {@code "Response"} matches {@code UserResponse}, + * {@code PetResponse}, …). Mutually exclusive with {@link #prefix}. + */ + public String suffix; + + /** + * Schema name prefix to match (e.g. {@code "Api"} matches {@code ApiUser}, + * {@code ApiPet}, …). Mutually exclusive with {@link #suffix}. + */ + public String prefix; + + /** + * Target generic class name. + * + *
    + *
  • FQN (contains {@code .}): Mode A — add to importMapping, no file generated.
  • + *
  • Simple name (no {@code .}): Mode B — generate class in configPackage.
  • + *
+ * + * May be {@code null} or empty to skip this entry. + */ + public String genericClass; + + /** + * Name of the property that serves as the single {@code $ref} type slot. + * The property's referenced schema becomes type argument {@code T}. + * Mutually exclusive with {@link #slotArray} and {@link #slots}. + */ + public String slot; + + /** + * Name of the array property whose items serve as type argument {@code T}. + * Mutually exclusive with {@link #slot} and {@link #slots}. + */ + public String slotArray; + + /** + * Multi-slot configuration mapping property names to type parameter names. + * E.g. {@code {"data": "T", "error": "E"}} declares two type parameters. + * When present, takes precedence over {@link #slot} and {@link #slotArray}. + * Array-ness of each slot property is auto-detected from the matched schema. + */ + public Map slots; + + public GenericPatternConfig() {} + + /** Fluent convenience constructor for testing. */ + public GenericPatternConfig suffix(String s) { this.suffix = s; return this; } + public GenericPatternConfig prefix(String p) { this.prefix = p; return this; } + public GenericPatternConfig genericClass(String g) { this.genericClass = g; return this; } + public GenericPatternConfig slot(String s) { this.slot = s; return this; } + public GenericPatternConfig slotArray(String s) { this.slotArray = s; return this; } + public GenericPatternConfig slots(Map s) { this.slots = s; return this; } + + @Override + public String toString() { + return "GenericPatternConfig{suffix=" + suffix + ", prefix=" + prefix + + ", genericClass=" + genericClass + ", slot=" + slot + + ", slotArray=" + slotArray + ", slots=" + slots + "}"; + } +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericSchemaScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericSchemaScanUtils.java new file mode 100644 index 000000000000..3074a656c2c8 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericSchemaScanUtils.java @@ -0,0 +1,908 @@ +/* + * 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 io.swagger.v3.oas.models.media.Schema; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Language-agnostic utility for detecting OpenAPI schemas that can be replaced by a single + * generic class ({@code ApiResponse}, {@code Page}, …) during code generation. + * + *

Three detection tiers are supported:

+ *
    + *
  1. Tier 1 — Vendor extensions: schema carries {@code x-generic-class} and + * {@code x-generic-args} extensions (requires spec modification).
  2. + *
  3. Tier 2 — Suffix / prefix patterns: schemas whose name matches a configured + * suffix or prefix pattern (requires only generator config, not spec changes).
  4. + *
  5. Tier 3 — Structural clustering: schemas with the same structure except for + * one varying {@code $ref} property are clustered and logged as suggestions. + * This tier never auto-applies substitution.
  6. + *
+ * + *

Used by {@link GenericSubstitutionSupport} which holds the stateful result + * and coordinates code generation lifecycle hooks.

+ */ +public final class GenericSchemaScanUtils { + + // No static LOGGER field — project's ArchUnit rule forbids static loggers + // (see ArchUnitRulesTest.LOGGERS_SHOULD_BE_NOT_PUBLIC_NOT_STATIC_AND_FINAL). + // Static methods access the logger via this lazy helper instead. The slf4j + // LoggerFactory caches loggers internally, so repeated lookups are essentially free. + private static Logger logger() { + return LoggerFactory.getLogger(GenericSchemaScanUtils.class); + } + + /** Type parameter name sequence used when auto-assigning names by position. */ + private static final String[] TYPE_PARAM_LETTERS = {"T", "E", "U", "V", "W"}; + + private GenericSchemaScanUtils() {} + + /** Returns the type parameter name for the given 0-based slot index. */ + private static String typeParamLetter(int index) { + return index < TYPE_PARAM_LETTERS.length ? TYPE_PARAM_LETTERS[index] : "T" + index; + } + + // ========================================================================= + // Data classes + // ========================================================================= + + /** + * Describes a single property of a schema, used when generating the generic class source + * in Mode B. + */ + public static final class GenericProperty { + /** Property name. */ + public final String name; + /** + * OpenAPI schema type of this property: {@code "string"}, {@code "integer"}, + * {@code "number"}, {@code "boolean"}, {@code "$ref"}, {@code "array"}, or + * {@code "object"}. Never {@code null}. + */ + public final String openApiType; + /** + * For {@code $ref} properties: the simple schema name of the referenced schema + * (e.g. {@code "User"}). For {@code array} properties with items {@code $ref}: + * the simple name of the items schema. {@code null} for primitives. + */ + public final String refTarget; + /** + * Name of the type parameter assigned to this property (e.g. {@code "T"}). + * Non-null only for the slot property. When non-null this property's generated + * Java/Kotlin type will be {@code T} (or {@code List} if {@code isArray}). + */ + public final String typeParam; + /** OpenAPI format string (e.g. {@code "int64"}, {@code "date-time"}), may be {@code null}. */ + public final String format; + /** {@code true} if this is an array property (type=array). */ + public final boolean isArray; + /** {@code true} if the property is listed in the schema's {@code required} list. */ + public final boolean required; + + public GenericProperty(String name, String openApiType, String refTarget, + String typeParam, String format, boolean isArray, boolean required) { + this.name = name; + this.openApiType = openApiType; + this.refTarget = refTarget; + this.typeParam = typeParam; + this.format = format; + this.isArray = isArray; + this.required = required; + } + } + + /** + * Carries the result of detecting a single generic schema instance. + * + *

Used for both return-type substitution and (in Mode B) class file generation.

+ */ + public static final class GenericInstance { + /** Concrete schema name (e.g. {@code "UserResponse"}). */ + public final String schemaName; + /** Simple class name of the generic class (e.g. {@code "ApiResponse"}). */ + public final String genericClassName; + /** + * Fully-qualified name of the generic class, or {@code null} if Mode B + * (class is to be generated in the config package). + */ + public final String genericClassFqn; + /** + * {@code true} if the generic class does not yet exist and should be generated + * (Mode B). {@code false} if it is an external class to be imported (Mode A). + */ + public final boolean generateClass; + /** + * Maps slot property name to resolved type-argument schema name. + * E.g. {@code {"data" -> "User"}} (single-param) or + * {@code {"data" -> "User", "error" -> "ValidationError"}} (multi-param). + */ + public final Map typeArgs; + /** + * Maps slot property name to type parameter name (e.g. {@code "T"}, {@code "E"}). + * Same key set and insertion order as {@link #typeArgs}. + * E.g. {@code {"data" -> "T"}} or {@code {"data" -> "T", "error" -> "E"}}. + */ + public final Map slotTypeParams; + /** + * The name of the primary (first) slot property. + */ + public final String slotProperty; + /** + * Whether the primary slot property is an array property, meaning the + * generated type will be {@code List} rather than {@code T}. + */ + public final boolean slotIsArray; + /** + * All properties of the matched schema, with slot properties having their + * respective {@code typeParam} set. Used for Mode B class generation. + */ + public final List properties; + + public GenericInstance(String schemaName, String genericClassName, String genericClassFqn, + boolean generateClass, Map typeArgs, + Map slotTypeParams, + String slotProperty, boolean slotIsArray, + List properties) { + this.schemaName = schemaName; + this.genericClassName = genericClassName; + this.genericClassFqn = genericClassFqn; + this.generateClass = generateClass; + this.typeArgs = Collections.unmodifiableMap(typeArgs); + this.slotTypeParams = Collections.unmodifiableMap(slotTypeParams); + this.slotProperty = slotProperty; + this.slotIsArray = slotIsArray; + this.properties = Collections.unmodifiableList(properties); + } + + /** + * Returns the type argument for the first (and usually only) slot. + * E.g. {@code "User"} for a {@code UserResponse} matched by slot {@code "data"}. + * + * @throws java.util.NoSuchElementException if {@code typeArgs} is empty (should never + * happen for instances produced by the scanner, but the public constructor + * does not enforce non-empty) + */ + public String firstTypeArg() { + return typeArgs.values().iterator().next(); + } + } + + /** + * A suggestion produced by Tier 3 structural clustering. + * Never auto-applied; only logged to guide the user in configuring Tier 2 patterns. + */ + public static final class ClusterSuggestion { + /** Names of schemas in the cluster (e.g. {@code ["LogEntry", "MetricsEntry"]}). */ + public final List schemaNames; + /** Property name that varies between cluster members (the candidate slot). */ + public final String varyingSlotProperty; + /** + * Whether the varying slot is an {@code array} of {@code $ref} (suggests {@code slotArray:}) + * rather than a plain {@code $ref} (suggests {@code slot:}). + */ + public final boolean isArraySlot; + /** $ref target names found across cluster members for the varying property. */ + public final List varyingTypes; + /** A ready-to-paste YAML snippet for a Tier 2 genericPatterns entry. */ + public final String suggestedConfig; + + public ClusterSuggestion(List schemaNames, String varyingSlotProperty, + List varyingTypes, String suggestedConfig) { + this(schemaNames, varyingSlotProperty, false, varyingTypes, suggestedConfig); + } + + public ClusterSuggestion(List schemaNames, String varyingSlotProperty, + boolean isArraySlot, List varyingTypes, + String suggestedConfig) { + this.schemaNames = Collections.unmodifiableList(schemaNames); + this.varyingSlotProperty = varyingSlotProperty; + this.isArraySlot = isArraySlot; + this.varyingTypes = Collections.unmodifiableList(varyingTypes); + this.suggestedConfig = suggestedConfig; + } + } + + // ========================================================================= + // Tier 1 — Vendor extension scanning + // ========================================================================= + + /** + * Scans all named schemas for {@code x-generic-class} / {@code x-generic-args} vendor + * extensions (Tier 1) and returns one {@link GenericInstance} per decorated schema. + * + * @param openAPI the parsed OpenAPI document + * @return list of detected instances; empty if none found + */ + public static List scanVendorExtensions(OpenAPI openAPI) { + List result = new ArrayList<>(); + if (openAPI.getComponents() == null || openAPI.getComponents().getSchemas() == null) { + return result; + } + + for (Map.Entry entry : openAPI.getComponents().getSchemas().entrySet()) { + String schemaName = entry.getKey(); + Schema schema = entry.getValue(); + if (schema.getExtensions() == null) { + continue; + } + + Object classExt = schema.getExtensions().get("x-generic-class"); + if (!(classExt instanceof String) || ((String) classExt).isEmpty()) { + continue; + } + + String genericClassValue = (String) classExt; + + // Parse x-generic-args: should be a Map + Object argsExt = schema.getExtensions().get("x-generic-args"); + Map typeArgs = new LinkedHashMap<>(); + if (argsExt instanceof Map) { + for (Map.Entry argEntry : ((Map) argsExt).entrySet()) { + typeArgs.put(String.valueOf(argEntry.getKey()), String.valueOf(argEntry.getValue())); + } + } + + if (typeArgs.isEmpty()) { + logger().warn("GenericSchemaScanUtils: schema '{}' has x-generic-class '{}' but no " + + "x-generic-args — skipping", schemaName, genericClassValue); + continue; + } + + // Identify slot properties and assign type param names by position (T, E, U, ...) + String slotProperty = typeArgs.keySet().iterator().next(); + boolean slotIsArray = false; + Map slotTypeParams = new LinkedHashMap<>(); + Map props = resolveProperties(schema, openAPI); + int tpIndex = 0; + for (String propName : typeArgs.keySet()) { + slotTypeParams.put(propName, typeParamLetter(tpIndex++)); + } + if (props != null) { + Schema slotSchema = (Schema) props.get(slotProperty); + if (slotSchema != null && "array".equals(slotSchema.getType())) { + slotIsArray = true; + } + } + + boolean isFqn = genericClassValue.contains("."); + String genericClassName = isFqn + ? genericClassValue.substring(genericClassValue.lastIndexOf('.') + 1) + : genericClassValue; + + List properties = buildProperties(schema, openAPI, slotTypeParams); + + result.add(new GenericInstance( + schemaName, genericClassName, + isFqn ? genericClassValue : null, + !isFqn, + typeArgs, slotTypeParams, slotProperty, slotIsArray, properties)); + + logger().debug("GenericSchemaScanUtils Tier1: schema '{}' → {}{}", + schemaName, genericClassName, + typeArgs.entrySet().stream() + .map(e -> "<" + e.getValue() + ">") + .collect(Collectors.joining())); + } + return result; + } + + // ========================================================================= + // Tier 2 — Config suffix / prefix pattern scanning + // ========================================================================= + + /** + * Scans all named schemas against the provided {@link GenericPatternConfig} list + * (Tier 2) and returns one {@link GenericInstance} per matched schema. + * + *

A schema matches a pattern when: + *

    + *
  • its name ends with {@link GenericPatternConfig#suffix} (case-sensitive), or
  • + *
  • its name starts with {@link GenericPatternConfig#prefix} (case-sensitive),
  • + *
+ * AND the schema has the expected slot property ({@link GenericPatternConfig#slot}) or + * slotArray property ({@link GenericPatternConfig#slotArray}) with a {@code $ref} or + * array-of-{@code $ref} type respectively.

+ * + *

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

+ *
    + *
  1. Create one instance per generator run and store it as a field.
  2. + *
  3. Call {@link #addPattern} in {@code processOpts()} for each configured pattern.
  4. + *
  5. Call {@link #preprocessOpenAPI} from the generator's {@code preprocessOpenAPI} override.
  6. + *
  7. Call {@link #substituteReturnType} from the generator's {@code fromOperation} override.
  8. + *
  9. Call {@link #suppressGenericSchemas} from {@code postProcessAllModels}.
  10. + *
+ * + *

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> typeParamList = new ArrayList<>(); + for (int i = 0; i < distinctTypeParams.size(); i++) { + Map tp = new LinkedHashMap<>(); + tp.put("typeParam", distinctTypeParams.get(i)); + tp.put("isLast", i == distinctTypeParams.size() - 1 ? Boolean.TRUE : null); + typeParamList.add(tp); + } + data.put("typeParams", typeParamList); + + List> propMaps = new ArrayList<>(); + for (GenericSchemaScanUtils.GenericProperty prop : instance.properties) { + Map pm = new LinkedHashMap<>(); + pm.put("name", prop.name); + pm.put("capitalName", capitalize(prop.name)); + pm.put("javaType", toJavaType(prop)); + pm.put("kotlinType", toKotlinType(prop)); + pm.put("required", prop.required ? Boolean.TRUE : null); + propMaps.add(pm); + } + data.put("properties", propMaps); + return data; + } + + // ========================================================================= + // Type mapping helpers + // ========================================================================= + + private static String toJavaType(GenericSchemaScanUtils.GenericProperty prop) { + if (prop.typeParam != null) { + return prop.isArray ? "List<" + prop.typeParam + ">" : prop.typeParam; + } + switch (prop.openApiType) { + case "$ref": return prop.refTarget != null ? prop.refTarget : "Object"; + case "string": return "String"; + case "integer": + return "int64".equals(prop.format) ? "Long" : "Integer"; + case "number": + return "float".equals(prop.format) ? "Float" : "Double"; + case "boolean": return "Boolean"; + case "array": + return prop.refTarget != null ? "List<" + prop.refTarget + ">" : "List"; + default: return "Object"; + } + } + + private static String toKotlinType(GenericSchemaScanUtils.GenericProperty prop) { + if (prop.typeParam != null) { + return prop.isArray ? "List<" + prop.typeParam + ">" : prop.typeParam; + } + switch (prop.openApiType) { + case "$ref": return prop.refTarget != null ? prop.refTarget : "Any"; + case "string": return "String"; + case "integer": + return "int64".equals(prop.format) ? "Long" : "Int"; + case "number": + return "float".equals(prop.format) ? "Float" : "Double"; + case "boolean": return "Boolean"; + case "array": + return prop.refTarget != null ? "List<" + prop.refTarget + ">" : "List"; + default: return "Any"; + } + } + + // ========================================================================= + // Utilities + // ========================================================================= + + private static String capitalize(String s) { + if (s == null || s.isEmpty()) return s; + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } + + // ========================================================================= + // Recursive generic type helpers + // ========================================================================= + + /** + * Builds the fully-expanded generic type string for the given instance, recursing + * into any type argument that is itself a registry entry. + * + *

Examples: + *

    + *
  • {@code UserResponse → ApiResponse}
  • + *
  • {@code UserResponsePage (Page<T> where T=UserResponse) + * → Page<ApiResponse<User>>}
  • + *
+ * + * @param inst the generic instance to expand + * @param ctx generator context (for {@code toModelName}) + * @param visited raw schema names already being expanded (cycle guard) + * @return e.g. {@code "ApiResponse"} or {@code "Page>"} + */ + private String buildGenericTypeName(GenericSchemaScanUtils.GenericInstance inst, + Context ctx, Set visited) { + if (!visited.add(inst.schemaName)) { + // Cycle detected — fall back to plain class name to avoid infinite recursion + return inst.genericClassName; + } + StringBuilder sb = new StringBuilder(inst.genericClassName).append("<"); + boolean first = true; + for (String slotProp : inst.slotTypeParams.keySet()) { + if (!first) sb.append(", "); + first = false; + String rawTypeArg = inst.typeArgs.get(slotProp); + String transformedTypeArg = ctx.toModelName(rawTypeArg); + GenericSchemaScanUtils.GenericInstance nestedInst = instanceRegistry.get(transformedTypeArg); + if (nestedInst != null) { + sb.append(buildGenericTypeName(nestedInst, ctx, visited)); + } else { + sb.append(transformedTypeArg); + } + } + sb.append(">"); + return sb.toString(); + } + + /** + * Recursively collects all import names that need to be added when + * substituting this instance: the generic class itself and all leaf type-arg names. + * + *

E.g. for {@code Page>}: adds {@code Page}, {@code ApiResponse}, + * {@code User}.

+ */ + private void collectImportsToAdd(GenericSchemaScanUtils.GenericInstance inst, + Context ctx, Set result, Set visited) { + if (!visited.add(inst.schemaName)) return; + result.add(inst.genericClassName); + for (String rawTypeArg : inst.typeArgs.values()) { + String transformed = ctx.toModelName(rawTypeArg); + GenericSchemaScanUtils.GenericInstance nestedInst = instanceRegistry.get(transformed); + if (nestedInst != null) { + collectImportsToAdd(nestedInst, ctx, result, visited); + } else { + result.add(transformed); + } + } + } + + /** + * Recursively collects the transformed schema names that should be + * removed from imports after substitution (the wrapper schemas that are + * being suppressed). + * + *

E.g. for {@code Page>}: collects the transformed name of + * {@code UserResponsePage} and the transformed name of {@code UserResponse}.

+ */ + private void collectSuppressedImports(GenericSchemaScanUtils.GenericInstance inst, + Context ctx, Set result, Set visited) { + if (!visited.add(inst.schemaName)) return; + result.add(ctx.toModelName(inst.schemaName)); + for (String rawTypeArg : inst.typeArgs.values()) { + String transformed = ctx.toModelName(rawTypeArg); + GenericSchemaScanUtils.GenericInstance nestedInst = instanceRegistry.get(transformed); + if (nestedInst != null) { + collectSuppressedImports(nestedInst, ctx, result, visited); + } + } + } + + // ========================================================================= + // Property-level substitution + // ========================================================================= + + /** + * Substitutes generic-instance schema references in all model properties. + * + *

Iterates all unique property instances across {@code vars}, {@code requiredVars}, + * {@code optionalVars}, and {@code allVars} (Kotlin Spring templates render from + * {@code requiredVars}/{@code optionalVars}; Java Spring from {@code vars}; + * {@code CodegenModel.removeAllDuplicatedProperty()} clones each list independently so + * the lists hold different instances). When a property's {@code baseType} or + * {@code complexType} matches a registry key, its type strings are rewritten to the + * fully-expanded generic form and the model's import sets are updated accordingly.

+ * + *

Only called when {@code annotationLibrary=none} (already gated by the caller).

+ */ + private void substitutePropertyTypes(Map objs, Context ctx) { + for (ModelsMap modelsMap : objs.values()) { + for (ModelMap modelMap : modelsMap.getModels()) { + CodegenModel model = modelMap.getModel(); + + // Collect all unique property instances across all property lists. + // Kotlin Spring templates render from requiredVars/optionalVars, Java Spring from vars. + // CodegenModel.removeAllDuplicatedProperty() clones each list independently, so vars + // and optionalVars/requiredVars hold DIFFERENT instances for the same property. + // Using IdentityHashMap ensures each physical instance is processed exactly once. + Set allProps = Collections.newSetFromMap(new IdentityHashMap<>()); + allProps.addAll(model.vars); + allProps.addAll(model.requiredVars); + allProps.addAll(model.optionalVars); + allProps.addAll(model.allVars); + + Set removedFromImports = new HashSet<>(); + Set addedToImports = new HashSet<>(); + + for (CodegenProperty prop : allProps) { + // For plain model-ref properties the instance name is in baseType. + // For array/container properties baseType = "List" and the item type is in complexType. + String lookupKey = prop.baseType; + GenericSchemaScanUtils.GenericInstance inst = + lookupKey != null ? instanceRegistry.get(lookupKey) : null; + final boolean usingComplexTypeFallback; + if (inst == null && prop.complexType != null + && !prop.complexType.equals(prop.baseType)) { + lookupKey = prop.complexType; + inst = instanceRegistry.get(lookupKey); + usingComplexTypeFallback = inst != null; + } else { + usingComplexTypeFallback = false; + } + if (inst == null) continue; + + String newGenericType = buildGenericTypeName(inst, ctx, new HashSet<>()); + + // Replace all occurrences of the old type in the type strings. + // Handles plain "UserResponse", "List", nullable "UserResponse?" etc. + // + // Substring-collision invariant: this relies on lookupKey not being a substring + // of any other registry key whose property might land in the same dataType + // (e.g. registering both "User" and "UserPage" as generic instances would + // corrupt a dataType of "UserPage"). In practice schema names are full identifiers + // — there is no realistic OpenAPI spec where two distinct generic-instance schema + // names exhibit this prefix relationship and reference each other in one property. + prop.dataType = prop.dataType.replace(lookupKey, newGenericType); + if (prop.datatypeWithEnum != null) { + prop.datatypeWithEnum = prop.datatypeWithEnum.replace(lookupKey, newGenericType); + } + // Update baseType only when it was the matched key (not for array container types). + if (!usingComplexTypeFallback) { + prop.baseType = inst.genericClassName; + } + if (usingComplexTypeFallback || lookupKey.equals(prop.complexType)) { + prop.complexType = inst.genericClassName; + // Also update prop.items for array/map properties: + // templates like pojo.mustache use items.datatypeWithEnum for addXxxItem methods. + if (prop.items != null) { + prop.items.dataType = prop.items.dataType.replace(lookupKey, newGenericType); + if (prop.items.datatypeWithEnum != null) { + prop.items.datatypeWithEnum = + prop.items.datatypeWithEnum.replace(lookupKey, newGenericType); + } + if (lookupKey.equals(prop.items.baseType)) { + prop.items.baseType = inst.genericClassName; + } + if (lookupKey.equals(prop.items.complexType)) { + prop.items.complexType = inst.genericClassName; + } + } + } + + // Update model-level imports Set and track changes for List sync below. + if (model.imports.remove(lookupKey)) { + removedFromImports.add(lookupKey); + } + Set toAdd = new HashSet<>(); + collectImportsToAdd(inst, ctx, toAdd, new HashSet<>()); + for (String cn : toAdd) { + if (model.imports.add(cn)) { + addedToImports.add(cn); + } + } + + LOGGER.info("GenericSubstitutionSupport: model '{}' property '{}': " + + "substituted '{}' → '{}'", + model.name, prop.name, lookupKey, newGenericType); + } + + // Synchronize ModelsMap.imports (List>) with the updated + // model.imports Set. DefaultGenerator builds this List before postProcessAllModels, + // so it must be updated here to ensure templates see the correct import statements. + if (!removedFromImports.isEmpty() || !addedToImports.isEmpty()) { + syncModelsMapImports(modelsMap, removedFromImports, addedToImports, ctx); + } + } + } + } + + /** + * Synchronizes the pre-built {@code ModelsMap.imports} List with changes made to + * {@code model.imports} during property-type substitution. + * + *

Removes FQN entries whose simple class name is in {@code removed} and adds + * FQN entries for simple class names in {@code added} that are not yet present.

+ */ + private void syncModelsMapImports(ModelsMap modelsMap, Set removed, + Set added, Context ctx) { + List> importsList = modelsMap.getImports(); + if (importsList == null) return; + + // Remove entries for types that were substituted away. + for (String simpleName : removed) { + final String suffix = "." + simpleName; + importsList.removeIf(imp -> { + String fqn = imp.get("import"); + return fqn != null && (fqn.endsWith(suffix) || fqn.equals(simpleName)); + }); + } + + // Add entries for newly required types. + Set existingFqns = new HashSet<>(); + for (Map imp : importsList) { + String fqn = imp.get("import"); + if (fqn != null) existingFqns.add(fqn); + } + for (String simpleName : added) { + String fqn = ctx.importMapping().get(simpleName); + if (fqn == null) fqn = ctx.toModelImport(simpleName); + if (fqn != null && !existingFqns.contains(fqn)) { + Map entry = new HashMap<>(); + entry.put("import", fqn); + importsList.add(entry); + existingFqns.add(fqn); + } + } + } + + /** + * Returns {@code true} if any model in {@code objs} still references the given + * transformed instance name — either via a property {@code baseType} / {@code complexType} + * that was not substituted (checked across {@code vars}, {@code requiredVars}, + * {@code optionalVars}, and {@code allVars} since {@code removeAllDuplicatedProperty()} + * gives each list independent property instances), via {@code model.parent} + * (allOf inheritance), or via the model's imports set. + * + *

This is used as a suppression safety check to avoid deleting a class that is + * still needed (e.g. as a base class for another model).

+ */ + private boolean isStillReferenced(String transformedKey, Map objs) { + for (ModelsMap modelsMap : objs.values()) { + for (ModelMap modelMap : modelsMap.getModels()) { + CodegenModel model = modelMap.getModel(); + if (transformedKey.equals(model.parent)) { + return true; + } + if (model.imports != null && model.imports.contains(transformedKey)) { + return true; + } + Set allProps = Collections.newSetFromMap(new IdentityHashMap<>()); + allProps.addAll(model.vars); + allProps.addAll(model.requiredVars); + allProps.addAll(model.optionalVars); + allProps.addAll(model.allVars); + for (CodegenProperty prop : allProps) { + if (transformedKey.equals(prop.baseType) + || transformedKey.equals(prop.complexType)) { + return true; + } + } + } + } + return false; + } + + /** + * Returns the number of detected generic instances (for testing). + */ + public int instanceCount() { + return instanceRegistry.size(); + } + + /** + * Returns the instance registry (for testing / inspection). + */ + public Map getInstanceRegistry() { + return Collections.unmodifiableMap(instanceRegistry); + } +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index 62d602f6a6ef..e7306ecb8a39 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java @@ -22,9 +22,6 @@ import com.samskivert.mustache.Template; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; -import io.swagger.v3.oas.models.PathItem; -import io.swagger.v3.oas.models.media.Schema; -import io.swagger.v3.oas.models.parameters.Parameter; import lombok.Getter; import lombok.Setter; import org.openapitools.codegen.*; @@ -63,7 +60,8 @@ * A library-specific template shadows a root-level template of the same name. */ public class KotlinSpringServerCodegen extends AbstractKotlinCodegen - implements BeanValidationFeatures, DocumentationProviderFeatures, SwaggerUIFeatures { + implements BeanValidationFeatures, DocumentationProviderFeatures, SwaggerUIFeatures, + SpringPageableSupport.Context, GenericSubstitutionSupport.Context { private final Logger LOGGER = LoggerFactory.getLogger(KotlinSpringServerCodegen.class); @@ -110,6 +108,8 @@ public class KotlinSpringServerCodegen extends AbstractKotlinCodegen public static final String GENERATE_SORT_VALIDATION = "generateSortValidation"; public static final String GENERATE_PAGEABLE_CONSTRAINT_VALIDATION = "generatePageableConstraintValidation"; public static final String SUBSTITUTE_GENERIC_PAGED_MODEL = "substituteGenericPagedModel"; + public static final String GENERIC_PATTERNS = "genericPatterns"; + public static final String DISCOVER_GENERIC_PATTERNS = "discoverGenericPatterns"; public static final String USE_SEALED_RESPONSE_INTERFACES = "useSealedResponseInterfaces"; public static final String COMPANION_OBJECT = "companionObject"; public static final String SUSPEND_FUNCTIONS = "suspendFunctions"; @@ -176,12 +176,43 @@ public String getDescription() { @Setter private boolean beanQualifiers = false; @Setter private DeclarativeInterfaceReactiveMode declarativeInterfaceReactiveMode = DeclarativeInterfaceReactiveMode.coroutines; @Setter private boolean useResponseEntity = true; - @Setter private boolean autoXSpringPaginated = false; - @Setter private boolean generateSortValidation = false; - @Setter private boolean generatePageableConstraintValidation = false; - @Setter private boolean substituteGenericPagedModel = false; @Setter private boolean useSealedResponseInterfaces = false; + + private final SpringPageableSupport pageableSupport = new SpringPageableSupport(); + private final GenericSubstitutionSupport genericSubstitutionSupport = new GenericSubstitutionSupport(); + + // Delegating setters — state lives in pageableSupport + public void setAutoXSpringPaginated(boolean v) { pageableSupport.setAutoXSpringPaginated(v); } + public void setGenerateSortValidation(boolean v) { pageableSupport.setGenerateSortValidation(v); } + public void setGeneratePageableConstraintValidation(boolean v) { pageableSupport.setGeneratePageableConstraintValidation(v); } + public void setSubstituteGenericPagedModel(boolean v) { pageableSupport.setSubstituteGenericPagedModel(v); } + + // SpringPageableSupport.Context implementation — methods not already provided by the base class + @Override public String getSourceFolder() { return sourceFolder; } + @Override public boolean isUseBeanValidation() { return useBeanValidation; } + @Override public void applySpringdocPageableAnnotation(CodegenOperation op) { + if (DocumentationProvider.SPRINGDOC.equals(getDocumentationProvider())) { + op.imports.add("PageableAsQueryParam"); + Object existingAnnotation = op.vendorExtensions.get("x-operation-extra-annotation"); + List annotations = DefaultCodegen.getObjectAsStringList(existingAnnotation); + List updatedAnnotations = new ArrayList<>(); + updatedAnnotations.add("@PageableAsQueryParam"); + updatedAnnotations.addAll(annotations); + op.vendorExtensions.put("x-operation-extra-annotation", updatedAnnotations); + } + } + + // GenericSubstitutionSupport.Context implementation + @Override public String fileExtension() { return "kt"; } + @Override public String toModelImport(String className) { + if (className.contains(".")) return className; // already fully qualified (e.g. from schemaMapping) + return modelPackage() + "." + className; + } + @Override public Map schemaMapping() { return schemaMapping; } + @Setter private boolean companionObject = false; + @Setter private boolean useEnumValueInterface = false; + private String valuedEnumClassName = "ValuedEnum"; @Setter private boolean suspendFunctions = false; @Getter @Setter private boolean openApiNullable = false; @Getter @Setter @@ -200,20 +231,6 @@ public String getDescription() { private Map sealedInterfaceToOperationId = new HashMap<>(); private boolean sealedInterfacesFileWritten = false; - // Map from operationId to allowed sort values for @ValidSort annotation generation - private Map> sortValidationEnums = new HashMap<>(); - - // Map from operationId to pageable defaults for @PageableDefault/@SortDefault annotation generation - private Map pageableDefaultsRegistry = new HashMap<>(); - - // Map from operationId to pageable constraints for @ValidPageable annotation generation - private Map pageableConstraintsRegistry = new HashMap<>(); - - // Map from schema name to detected paged-model info (populated when substituteGenericPagedModel=true) - private Map pagedModelRegistry = new HashMap<>(); - // Simple class name of the PagedModel substitute (derived from importMapping; defaults to "PagedModel") - private String pagedModelClassName = "PagedModel"; - public KotlinSpringServerCodegen() { super(); @@ -306,18 +323,27 @@ public KotlinSpringServerCodegen() { addOption(X_KOTLIN_IMPLEMENTS_FIELDS_SKIP, "A list of fields per schema name that should NOT be created with `override` keyword despite their presence in vendor extension `x-kotlin-implements-fields` for the schema. Example: yaml `xKotlinImplementsFieldsSkip: Pet: [photoUrls]` skips `override` for `photoUrls` in schema `Pet`", "empty map"); addOption(SCHEMA_IMPLEMENTS, "A map of single interface or a list of interfaces per schema name that should be implemented (serves similar purpose as `x-kotlin-implements`, but is fully decoupled from the api spec). Example: yaml `schemaImplements: {Pet: com.some.pack.WithId, Category: [com.some.pack.CategoryInterface], Dog: [com.some.pack.Canine, com.some.pack.OtherInterface]}` implements interfaces in schemas `Pet` (interface `com.some.pack.WithId`), `Category` (interface `com.some.pack.CategoryInterface`), `Dog`(interfaces `com.some.pack.Canine`, `com.some.pack.OtherInterface`)", "empty map"); addOption(SCHEMA_IMPLEMENTS_FIELDS, "A map of single field or a list of fields per schema name that should be prepended with `override` (serves similar purpose as `x-kotlin-implements-fields`, but is fully decoupled from the api spec). Example: yaml `schemaImplementsFields: {Pet: id, Category: [name, id], Dog: [bark, breed]}` marks fields to be prepended with `override` in schemas `Pet` (field `id`), `Category` (fields `name`, `id`) and `Dog` (fields `bark`, `breed`)", "empty map"); - addSwitch(AUTO_X_SPRING_PAGINATED, "Automatically add x-spring-paginated to operations that have 'page', 'size', and 'sort' query parameters. When enabled, operations with all three parameters will have Pageable support automatically applied. Operations with x-spring-paginated explicitly set to false will not be auto-detected.", autoXSpringPaginated); - addSwitch(GENERATE_SORT_VALIDATION, "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.", generateSortValidation); - addSwitch(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "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.", generatePageableConstraintValidation); + addSwitch(AUTO_X_SPRING_PAGINATED, "Automatically add x-spring-paginated to operations that have 'page', 'size', and 'sort' query parameters. When enabled, operations with all three parameters will have Pageable support automatically applied. Operations with x-spring-paginated explicitly set to false will not be auto-detected.", false); + addSwitch(GENERATE_SORT_VALIDATION, "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); + addSwitch(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "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); addSwitch(SUBSTITUTE_GENERIC_PAGED_MODEL, "Detect schemas that represent paginated responses (an object with a 'content' array property and a 'page' " + "pagination-metadata property) and replace their generated references with " + "PagedModel. By default this uses a generated type in the config package (default 'org.openapitools.configuration'), but `importMappings.PagedModel` can override it to a custom/FQCN-mapped type. The detected page schemas and the pagination metadata " + "schema are suppressed from code generation.", - substituteGenericPagedModel); + false); + addOption(GENERIC_PATTERNS, + "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); + addSwitch(DISCOVER_GENERIC_PATTERNS, + "When true, scans schemas for structural clusters and logs them as INFO-level suggestions for configuring genericPatterns.", + false); addSwitch(COMPANION_OBJECT, "Whether to generate companion objects in data classes, enabling companion extensions.", companionObject); addSwitch(SUSPEND_FUNCTIONS, "Whether to generate suspend functions for API operations. Useful for Spring MVC with Kotlin coroutines without requiring the full reactive stack.", suspendFunctions); cliOptions.add(CliOption.newBoolean(CodegenConstants.USE_DEDUCTION_FOR_ONE_OF_INTERFACES, CodegenConstants.USE_DEDUCTION_FOR_ONE_OF_INTERFACES_DESC, useDeductionForOneOfInterfaces)); + addSwitch(CodegenConstants.USE_ENUM_VALUE_INTERFACE, CodegenConstants.USE_ENUM_VALUE_INTERFACE_DESC, useEnumValueInterface); addSwitch(CodegenConstants.OPENAPI_NULLABLE, "Enable OpenAPI Jackson Nullable library (jackson-databind-nullable) for optional + nullable " + "properties (required: false, nullable: true). When enabled, such properties use " @@ -590,6 +616,7 @@ public void processOpts() { } convertPropertyToBooleanAndWriteBack(CodegenConstants.USE_DEDUCTION_FOR_ONE_OF_INTERFACES, this::setUseDeductionForOneOfInterfaces); + convertPropertyToBooleanAndWriteBack(CodegenConstants.USE_ENUM_VALUE_INTERFACE, this::setUseEnumValueInterface); additionalProperties.put("springHttpStatus", new SpringHttpStatusLambda()); @@ -765,19 +792,49 @@ public void processOpts() { if (additionalProperties.containsKey(AUTO_X_SPRING_PAGINATED) && library.equals(SPRING_BOOT)) { this.setAutoXSpringPaginated(convertPropertyToBoolean(AUTO_X_SPRING_PAGINATED)); } - writePropertyBack(AUTO_X_SPRING_PAGINATED, autoXSpringPaginated); + writePropertyBack(AUTO_X_SPRING_PAGINATED, pageableSupport.isAutoXSpringPaginated()); if (additionalProperties.containsKey(GENERATE_SORT_VALIDATION) && library.equals(SPRING_BOOT)) { this.setGenerateSortValidation(convertPropertyToBoolean(GENERATE_SORT_VALIDATION)); } - writePropertyBack(GENERATE_SORT_VALIDATION, generateSortValidation); + writePropertyBack(GENERATE_SORT_VALIDATION, pageableSupport.isGenerateSortValidation()); if (additionalProperties.containsKey(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION) && library.equals(SPRING_BOOT)) { this.setGeneratePageableConstraintValidation(convertPropertyToBoolean(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION)); } - writePropertyBack(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, generatePageableConstraintValidation); + writePropertyBack(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, pageableSupport.isGeneratePageableConstraintValidation()); if (additionalProperties.containsKey(SUBSTITUTE_GENERIC_PAGED_MODEL)) { this.setSubstituteGenericPagedModel(convertPropertyToBoolean(SUBSTITUTE_GENERIC_PAGED_MODEL)); } - writePropertyBack(SUBSTITUTE_GENERIC_PAGED_MODEL, substituteGenericPagedModel); + writePropertyBack(SUBSTITUTE_GENERIC_PAGED_MODEL, pageableSupport.isSubstituteGenericPagedModel()); + + // Parse genericPatterns from additionalProperties + Object rawPatterns = additionalProperties.get(GENERIC_PATTERNS); + if (rawPatterns instanceof List) { + for (Object item : (List) rawPatterns) { + if (item instanceof Map) { + Map map = (Map) item; + GenericPatternConfig cfg = new GenericPatternConfig(); + if (map.get("suffix") instanceof String) cfg.suffix = (String) map.get("suffix"); + if (map.get("prefix") instanceof String) cfg.prefix = (String) map.get("prefix"); + if (map.get("genericClass") instanceof String) cfg.genericClass = (String) map.get("genericClass"); + if (map.get("slot") instanceof String) cfg.slot = (String) map.get("slot"); + if (map.get("slotArray") instanceof String) cfg.slotArray = (String) map.get("slotArray"); + if (map.get("slots") instanceof Map) { + Map slots = new java.util.LinkedHashMap<>(); + ((Map) map.get("slots")).forEach((k, v) -> { + if (k instanceof String && v instanceof String) slots.put((String) k, (String) v); + }); + if (!slots.isEmpty()) cfg.slots = slots; + } + genericSubstitutionSupport.addPattern(cfg); + } + } + } + Object rawDiscover = additionalProperties.get(DISCOVER_GENERIC_PATTERNS); + if (rawDiscover instanceof Boolean) { + genericSubstitutionSupport.setDiscoverGenericPatterns((Boolean) rawDiscover); + } else if ("true".equals(rawDiscover)) { + genericSubstitutionSupport.setDiscoverGenericPatterns(true); + } if (isUseSpringBoot3() && isUseSpringBoot4()) { throw new IllegalArgumentException("Choose between Spring Boot 3 and Spring Boot 4"); } @@ -1057,144 +1114,18 @@ public void addOperationToGroup(String tag, String resourcePath, Operation opera */ @Override public CodegenOperation fromOperation(String path, String httpMethod, Operation operation, List servers) { - // #8315 Spring Data Web default query params recognized by Pageable - List defaultPageableQueryParams = Arrays.asList("page", "size", "sort"); + // Auto-detect pagination parameters before super.fromOperation so the extension is + // copied to codegenOperation.vendorExtensions by the base class. + pageableSupport.autoDetectPagination(operation, library); CodegenOperation codegenOperation = super.fromOperation(path, httpMethod, operation, servers); - // Check if operation has all three pagination query parameters (case-sensitive) - boolean hasParamsForPageable = codegenOperation.queryParams.stream() - .map(p -> p.baseName) - .collect(Collectors.toSet()) - .containsAll(defaultPageableQueryParams); - // Auto-detect pagination parameters and add x-spring-paginated if autoXSpringPaginated is enabled - // Only for spring-boot library, respect manual x-spring-paginated: false setting - if (SPRING_BOOT.equals(library) && autoXSpringPaginated) { - // Check if x-spring-paginated is not explicitly set to false - if (operation.getExtensions() == null || !Boolean.FALSE.equals(operation.getExtensions().get("x-spring-paginated"))) { - - - if (hasParamsForPageable) { - // Automatically add x-spring-paginated to the operation - if (operation.getExtensions() == null) { - operation.setExtensions(new HashMap<>()); - } - operation.getExtensions().put("x-spring-paginated", Boolean.TRUE); - codegenOperation.vendorExtensions.put("x-spring-paginated", Boolean.TRUE); - } - } - } - - // Only process x-spring-paginated for server-side libraries (spring-boot) - // Client libraries (spring-cloud, spring-declarative-http-interface) need actual query parameters for HTTP requests - if (SPRING_BOOT.equals(library)) { - // add Pageable import only if x-spring-paginated explicitly used AND it's a server library - // this allows to use a custom Pageable schema without importing Spring Pageable. - if (operation.getExtensions() != null && Boolean.TRUE.equals(operation.getExtensions().get("x-spring-paginated"))) { - importMapping.putIfAbsent("Pageable", "org.springframework.data.domain.Pageable"); - } - - // add org.springframework.data.domain.Pageable import when needed (server libraries only) - if (operation.getExtensions() != null && Boolean.TRUE.equals(operation.getExtensions().get("x-spring-paginated"))) { - codegenOperation.imports.add("Pageable"); - if (DocumentationProvider.SPRINGDOC.equals(getDocumentationProvider())) { - codegenOperation.imports.add("PageableAsQueryParam"); - // Prepend @PageableAsQueryParam to existing x-operation-extra-annotation if present - // Use getObjectAsStringList to properly handle both list and string formats: - // - YAML list: ['@Ann1', '@Ann2'] -> List of annotations - // - Single string: '@Ann1 @Ann2' -> Single-element list - // - Nothing/null -> Empty list - Object existingAnnotation = codegenOperation.vendorExtensions.get("x-operation-extra-annotation"); - List annotations = DefaultCodegen.getObjectAsStringList(existingAnnotation); - - // Prepend @PageableAsQueryParam to the beginning of the list - List updatedAnnotations = new ArrayList<>(); - updatedAnnotations.add("@PageableAsQueryParam"); - updatedAnnotations.addAll(annotations); - - codegenOperation.vendorExtensions.put("x-operation-extra-annotation", updatedAnnotations); - } - - // #8315 Remove matching Spring Data Web default query params if 'x-spring-paginated' with Pageable is used - // Build pageable parameter annotations (@ValidPageable, @ValidSort, @PageableDefault, @SortDefault.SortDefaults) - List pageableAnnotations = new ArrayList<>(); - - if (generatePageableConstraintValidation && useBeanValidation && pageableConstraintsRegistry.containsKey(codegenOperation.operationId)) { - SpringPageableScanUtils.PageableConstraintsData constraints = pageableConstraintsRegistry.get(codegenOperation.operationId); - List attrs = new ArrayList<>(); - if (constraints.maxSize >= 0) attrs.add("maxSize = " + constraints.maxSize); - if (constraints.maxPage >= 0) attrs.add("maxPage = " + constraints.maxPage); - pageableAnnotations.add("@ValidPageable(" + String.join(", ", attrs) + ")"); - codegenOperation.imports.add("ValidPageable"); - } - - if (generateSortValidation && useBeanValidation && sortValidationEnums.containsKey(codegenOperation.operationId)) { - List allowedSortValues = sortValidationEnums.get(codegenOperation.operationId); - String allowedValuesStr = allowedSortValues.stream() - .map(v -> "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\"") - .collect(Collectors.joining(", ")); - pageableAnnotations.add("@ValidSort(allowedValues = [" + allowedValuesStr + "])"); - codegenOperation.imports.add("ValidSort"); - } - - // Generate @PageableDefault / @SortDefault.SortDefaults annotations if defaults are present - if (pageableDefaultsRegistry.containsKey(codegenOperation.operationId)) { - SpringPageableScanUtils.PageableDefaultsData defaults = pageableDefaultsRegistry.get(codegenOperation.operationId); + // Build pageable annotations, add Pageable imports, and remove page/size/sort params. + pageableSupport.processPageableAnnotations(codegenOperation, this, "[", "]"); - if (defaults.page != null || defaults.size != null) { - List attrs = new ArrayList<>(); - if (defaults.page != null) attrs.add("page = " + defaults.page); - if (defaults.size != null) attrs.add("size = " + defaults.size); - pageableAnnotations.add("@PageableDefault(" + String.join(", ", attrs) + ")"); - codegenOperation.imports.add("PageableDefault"); - } - - if (!defaults.sortDefaults.isEmpty()) { - List sortEntries = defaults.sortDefaults.stream() - .map(sf -> "SortDefault(sort = [\"" + sf.field + "\"], direction = Sort.Direction." + sf.direction + ")") - .collect(Collectors.toList()); - pageableAnnotations.add("@SortDefault.SortDefaults(" + String.join(", ", sortEntries) + ")"); - codegenOperation.imports.add("SortDefault"); - codegenOperation.imports.add("Sort"); - } - } - - if (!pageableAnnotations.isEmpty()) { - codegenOperation.vendorExtensions.put("x-pageable-extra-annotation", pageableAnnotations); - } - codegenOperation.queryParams.removeIf(param -> defaultPageableQueryParams.contains(param.baseName)); - codegenOperation.allParams.removeIf(param -> param.isQueryParam && defaultPageableQueryParams.contains(param.baseName)); - } - } - - // If substituteGenericPagedModel is enabled, replace paged-model return types - // with org.springframework.data.web.PagedModel. - if (substituteGenericPagedModel && !pagedModelRegistry.isEmpty() - && codegenOperation.returnBaseType != null) { - PagedModelScanUtils.DetectedPagedModel detected = - pagedModelRegistry.get(codegenOperation.returnBaseType); - if (detected != null) { - String oldType = codegenOperation.returnType; - // Run through toModelName so that schemaMappings (e.g. User → com.example.MyUser) - // are honored: the mapped name is used both in the type arg and for import resolution. - String itemType = toModelName(detected.itemSchemaName); - String newBaseType = pagedModelClassName + "<" + itemType + ">"; - codegenOperation.returnType = newBaseType; - codegenOperation.returnBaseType = pagedModelClassName; - // Clear any container flag — PagedModel is not itself a List/array - codegenOperation.returnContainer = null; - // Add item type import (needed for PagedModel in method signature) - codegenOperation.imports.add(itemType); - codegenOperation.imports.add(pagedModelClassName); - // Remove paged schema import when no annotations are generated — - // the class is suppressed and not referenced anywhere - if (getAnnotationLibrary() == AnnotationLibrary.NONE) { - codegenOperation.imports.remove(detected.schemaName); - } - LOGGER.info("substituteGenericPagedModel: operation '{}': replacing return type '{}' with {}<{}>", - codegenOperation.operationId, oldType, pagedModelClassName, itemType); - } - } + // Replace operation return types for generic schema patterns (genericPatterns feature) + // and substituteGenericPagedModel (paged-model schemas contributed via pre-scan). + genericSubstitutionSupport.substituteReturnType(codegenOperation, this); return codegenOperation; } @@ -1208,55 +1139,17 @@ public void preprocessOpenAPI(OpenAPI openAPI) { (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "EnumConverterConfiguration.kt")); } - if (SPRING_BOOT.equals(library) && generateSortValidation && useBeanValidation) { - sortValidationEnums = SpringPageableScanUtils.scanSortValidationEnums(openAPI, autoXSpringPaginated); - if (!sortValidationEnums.isEmpty()) { - importMapping.putIfAbsent("ValidSort", configPackage + ".ValidSort"); - supportingFiles.add(new SupportingFile("validSort.mustache", - (sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidSort.kt")); - } - } + pageableSupport.preprocessOpenAPI(openAPI, this, SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY, "kt"); - if (SPRING_BOOT.equals(library)) { - pageableDefaultsRegistry = SpringPageableScanUtils.scanPageableDefaults(openAPI, autoXSpringPaginated); - if (!pageableDefaultsRegistry.isEmpty()) { - importMapping.putIfAbsent("PageableDefault", "org.springframework.data.web.PageableDefault"); - importMapping.putIfAbsent("SortDefault", "org.springframework.data.web.SortDefault"); - importMapping.putIfAbsent("Sort", "org.springframework.data.domain.Sort"); - } - } + pageableSupport.contributeToGenericSubstitution(genericSubstitutionSupport, this); - if (SPRING_BOOT.equals(library) && generatePageableConstraintValidation && useBeanValidation) { - pageableConstraintsRegistry = SpringPageableScanUtils.scanPageableConstraints(openAPI, autoXSpringPaginated); - if (!pageableConstraintsRegistry.isEmpty()) { - importMapping.putIfAbsent("ValidPageable", configPackage + ".ValidPageable"); - supportingFiles.add(new SupportingFile("validPageable.mustache", - (sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidPageable.kt")); - } - } + genericSubstitutionSupport.preprocessOpenAPI(openAPI, this); - if (substituteGenericPagedModel) { - pagedModelRegistry = PagedModelScanUtils.scanPagedModels(openAPI); - if (!pagedModelRegistry.isEmpty()) { - boolean customMapping = importMapping.containsKey("PagedModel"); - importMapping.putIfAbsent("PagedModel", configPackage + ".PagedModel"); - if (!customMapping) { - // No custom class provided — generate the simple PagedModel into the config package. - supportingFiles.add(new SupportingFile("pagedModel.mustache", - (sourceFolder + File.separator + configPackage).replace(".", File.separator), "PagedModel.kt")); - } - // Derive the actual simple class name from the FQN in importMapping so that a - // custom mapping (e.g. "PagedModel" → "com.example.MyPagedModel") is respected. - // The simple name of the FQN becomes the token used in generated code, and is - // registered in importMapping so that template import resolution works. - String fqn = importMapping.get("PagedModel"); - pagedModelClassName = fqn.substring(fqn.lastIndexOf('.') + 1); - if (!pagedModelClassName.equals("PagedModel")) { - importMapping.putIfAbsent(pagedModelClassName, fqn); - } - LOGGER.info("substituteGenericPagedModel: detected {} paged-model schema(s): {}", - pagedModelRegistry.size(), pagedModelRegistry.keySet()); - } + if (useEnumValueInterface) { + valuedEnumClassName = EnumValueInterfaceUtils.setupInPreprocessOpenAPI( + importMapping, additionalProperties, supportingFiles, + sourceFolder, configPackage, + "enumValueInterface.mustache", "ValuedEnum.kt"); } if (!additionalProperties.containsKey(TITLE)) { @@ -1329,7 +1222,7 @@ public void preprocessOpenAPI(OpenAPI openAPI) { * Delegates to {@link SpringPageableScanUtils#willBePageable}. */ private boolean willBePageable(Operation operation) { - return SpringPageableScanUtils.willBePageable(operation, autoXSpringPaginated); + return SpringPageableScanUtils.willBePageable(operation, pageableSupport.isAutoXSpringPaginated()); } @Override @@ -1342,7 +1235,7 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert // Scenario 3: optional + non-nullable → block explicit JSON nulls via @JsonSetter(nulls = Nulls.FAIL). // Missing keys still succeed (default = null is used), but explicit {"field": null} fails deserialization. - if (!Boolean.TRUE.equals(property.required) && !Boolean.TRUE.equals(property.isNullable)) { + if (!property.required && !property.isNullable) { property.vendorExtensions.put("x-has-json-setter-nulls-fail", true); model.imports.add("JsonSetter"); model.imports.add("Nulls"); @@ -1350,15 +1243,15 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert // Scenario 4: optional + nullable with openApiNullable → use JsonNullable = JsonNullable.undefined() // so callers can distinguish between a missing key and an explicitly provided null. - if (openApiNullable && !Boolean.TRUE.equals(property.required) && Boolean.TRUE.equals(property.isNullable)) { + if (openApiNullable && !property.required && property.isNullable) { property.vendorExtensions.put("x-is-jackson-optional-nullable", true); model.imports.add("JsonNullable"); } //Add imports for Jackson - if (!Boolean.TRUE.equals(model.isEnum)) { + if (!model.isEnum) { model.imports.add("JsonProperty"); - if (Boolean.TRUE.equals(model.hasEnums)) { + if (model.hasEnums) { model.imports.add("JsonValue"); model.imports.add("JsonCreator"); } @@ -1418,45 +1311,7 @@ public Map postProcessAllModels(Map objs) } } - if (substituteGenericPagedModel && !pagedModelRegistry.isEmpty()) { - if (getAnnotationLibrary() == AnnotationLibrary.NONE) { - // No @ApiResponse annotations are generated when annotationLibrary=none, - // so paged schemas are not referenced anywhere → safe to suppress. - Set metaSchemasToCheck = new HashSet<>(); - for (PagedModelScanUtils.DetectedPagedModel detected : pagedModelRegistry.values()) { - if (detected.metaSchemaName != null) { - metaSchemasToCheck.add(detected.metaSchemaName); - } - } - // Remove paged schemas first so reference checks below reflect the post-suppression state. - for (Map.Entry entry : pagedModelRegistry.entrySet()) { - String schemaName = entry.getKey(); - PagedModelScanUtils.DetectedPagedModel detected = entry.getValue(); - if (objs.remove(schemaName) != null) { - LOGGER.info("substituteGenericPagedModel: suppressing model '{}' — replaced by PagedModel<{}>", - schemaName, detected.itemSchemaName); - } - } - // Suppress meta schemas only when no remaining (non-suppressed) schema references them. - // Example: if SearchResult has a 'page: PageMeta' property, PageMeta must be kept. - for (String metaName : metaSchemasToCheck) { - boolean referencedElsewhere = objs.values().stream() - .flatMap(mm -> mm.getModels().stream()) - .map(ModelMap::getModel) - .anyMatch(cm -> cm.imports.contains(metaName)); - if (referencedElsewhere) { - LOGGER.info("substituteGenericPagedModel: keeping pagination metadata model '{}'" - + " — referenced by a non-paged schema", metaName); - } else if (objs.remove(metaName) != null) { - LOGGER.info("substituteGenericPagedModel: suppressing pagination metadata model '{}'" - + " — replaced by PagedModel.PageMetadata", metaName); - } - } - } else { - LOGGER.info("substituteGenericPagedModel: keeping paged-model schemas (annotationLibrary={}) — @ApiResponse annotations reference them", - getAnnotationLibrary().toCliOptValue()); - } - } + objs = genericSubstitutionSupport.suppressGenericSchemas(objs, this); return objs; } @@ -1502,6 +1357,11 @@ private void markPropertyAsInherited(CodegenModel model, String baseName, String } } + @Override + public void prepareSupportingFile(Map bundle, SupportingFile file) { + genericSubstitutionSupport.prepareSupportingFile(bundle, file); + } + @Override public ModelsMap postProcessModelsEnum(ModelsMap objs) { objs = super.postProcessModelsEnum(objs); @@ -1528,7 +1388,7 @@ public ModelsMap postProcessModelsEnum(ModelsMap objs) { objs.getModels().stream() .map(ModelMap::getModel) - .filter(cm -> Boolean.TRUE.equals(cm.isEnum) && cm.allowableValues != null) + .filter(cm -> cm.isEnum && cm.allowableValues != null) .forEach(cm -> { cm.imports.add(importMapping.get("JsonValue")); cm.imports.add(importMapping.get("JsonCreator")); @@ -1591,6 +1451,12 @@ public ModelsMap postProcessModelsEnum(ModelsMap objs) { } } + if (useEnumValueInterface) { + EnumValueInterfaceUtils.injectInPostProcessModelsEnum( + objs, valuedEnumClassName, importMapping.get("ValuedEnum"), + VendorExtension.X_KOTLIN_IMPLEMENTS.getName()); + } + return objs; } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java index 67279c10d818..846348b4d763 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java @@ -21,6 +21,7 @@ import org.openapitools.codegen.utils.ModelUtils; import java.util.*; +import java.util.function.UnaryOperator; /** * Language-agnostic utility for detecting OpenAPI schemas that represent paginated responses @@ -61,21 +62,59 @@ private PagedModelScanUtils() {} /** * Carries the result of a single detected paged-model schema. * - * @param schemaName Name of the detected schema to suppress (e.g. {@code UserPage}). - * @param itemSchemaName Simple name of the array item type (e.g. {@code User}). - * @param metaSchemaName Name of the pagination-metadata schema to suppress - * (e.g. {@code PageMetadata}), or {@code null} if it could not - * be resolved to a named component. + *

Two name variants are stored for each schema:

+ *
    + *
  • raw ({@code rawSchemaName} / {@code rawMetaSchemaName}) — the original + * OpenAPI component-schema name as it appears in the spec. {@code DefaultGenerator} + * keys the {@code objs} map passed to {@code postProcessAllModels} by these raw + * names, so any {@code objs.remove(...)} call must use them. This is the variant + * consumed by {@link SpringPageableSupport#contributeToGenericSubstitution} when + * handing detections off to {@link GenericSubstitutionSupport}.
  • + *
  • transformed ({@code schemaName} / {@code metaSchemaName}) — the model name + * after the generator's {@code toModelName()} has been applied. Useful for matching + * against {@code codegenOperation.returnBaseType} or entries in + * {@code CodegenModel.imports}, both of which are toModelName-processed.
  • + *
+ * + *

When constructed via {@link #scanPagedModels(OpenAPI)} (no transform), the raw and + * transformed names are identical. When constructed via + * {@link #scanPagedModels(OpenAPI, UnaryOperator)} they may differ (e.g. + * {@code rawSchemaName="UserPage"}, {@code schemaName="UserPageDto"}).

+ * + * @param schemaName Transformed model name of the detected paged schema. + * @param itemSchemaName Raw item schema name (always raw; callers apply + * {@code toModelName()} at the point of use). + * @param metaSchemaName Transformed model name of the pagination-metadata schema, + * or {@code null} if unresolved. + * @param rawSchemaName Raw OpenAPI schema name of the paged schema. + * @param rawMetaSchemaName Raw OpenAPI schema name of the pagination-metadata schema, + * or {@code null} if unresolved. */ public static final class DetectedPagedModel { + /** Transformed model name. Useful for matching against {@code returnBaseType}. */ public final String schemaName; public final String itemSchemaName; + /** Transformed meta model name. Useful for {@code imports} checks. */ public final String metaSchemaName; - + /** Raw OpenAPI schema name. Use for {@code objs.remove()} in {@code postProcessAllModels}. */ + public final String rawSchemaName; + /** Raw OpenAPI meta schema name. Use for {@code objs.remove()} in {@code postProcessAllModels}. */ + public final String rawMetaSchemaName; + + /** + * Convenience constructor used when no name transform is active (raw == transformed). + */ public DetectedPagedModel(String schemaName, String itemSchemaName, String metaSchemaName) { + this(schemaName, itemSchemaName, metaSchemaName, schemaName, metaSchemaName); + } + + DetectedPagedModel(String schemaName, String itemSchemaName, String metaSchemaName, + String rawSchemaName, String rawMetaSchemaName) { this.schemaName = schemaName; this.itemSchemaName = itemSchemaName; this.metaSchemaName = metaSchemaName; + this.rawSchemaName = rawSchemaName; + this.rawMetaSchemaName = rawMetaSchemaName; } } @@ -112,7 +151,43 @@ public static Map scanPagedModels(OpenAPI openAPI) { } /** - * Returns {@code true} if the given schema looks like a pagination-metadata schema. + * Convenience overload that scans for paged-model schemas and immediately re-keys the + * resulting map by applying {@code toModelName} to every schema name. + * + *

Generator classes must use this overload (passing {@code this::toModelName}) so that + * the registry keys match the model-name-processed values used at lookup time + * (e.g. {@code codegenOperation.returnBaseType}, {@code objs} keys). This ensures + * correctness when {@code modelNameSuffix}, {@code modelNamePrefix}, {@code schemaMapping}, + * or {@code modelNameMapping} are active.

+ * + *

{@code itemSchemaName} inside each {@link DetectedPagedModel} is intentionally left as + * the raw spec name because every call site already passes it through {@code toModelName()} + * at the point of use.

+ * + * @param openAPI the parsed OpenAPI document + * @param toModelName name-transformation function supplied by the generator + * (typically {@code this::toModelName}) + * @return map from transformed schema name to {@link DetectedPagedModel} + */ + public static Map scanPagedModels( + OpenAPI openAPI, UnaryOperator toModelName) { + Map raw = scanPagedModels(openAPI); + if (raw.isEmpty()) { + return raw; + } + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : raw.entrySet()) { + DetectedPagedModel d = entry.getValue(); + String rawKey = entry.getKey(); + String newKey = toModelName.apply(rawKey); + String rawMeta = d.metaSchemaName; + String newMeta = rawMeta != null ? toModelName.apply(rawMeta) : null; + result.put(newKey, new DetectedPagedModel(newKey, d.itemSchemaName, newMeta, rawKey, rawMeta)); + } + return result; + } + + /** * *

The heuristic checks that at least {@value #PAGINATION_FIELD_THRESHOLD} of the * well-known field names ({@code size}, {@code number}, {@code page}, diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java index 2eebdd08a477..9ca2a2126f80 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java @@ -66,7 +66,8 @@ * A library-specific template shadows a root-level template of the same name. */ public class SpringCodegen extends AbstractJavaCodegen - implements BeanValidationFeatures, PerformBeanValidationFeatures, OptionalFeatures, SwaggerUIFeatures { + implements BeanValidationFeatures, PerformBeanValidationFeatures, OptionalFeatures, SwaggerUIFeatures, + SpringPageableSupport.Context, GenericSubstitutionSupport.Context { private final Logger LOGGER = LoggerFactory.getLogger(SpringCodegen.class); public static final String TITLE = "title"; public static final String SERVER_PORT = "serverPort"; @@ -122,6 +123,8 @@ public class SpringCodegen extends AbstractJavaCodegen public static final String GENERATE_SORT_VALIDATION = "generateSortValidation"; public static final String GENERATE_PAGEABLE_CONSTRAINT_VALIDATION = "generatePageableConstraintValidation"; public static final String SUBSTITUTE_GENERIC_PAGED_MODEL = "substituteGenericPagedModel"; + public static final String GENERIC_PATTERNS = "genericPatterns"; + public static final String DISCOVER_GENERIC_PATTERNS = "discoverGenericPatterns"; public static final String CLIENT_REGISTRATION_ID = "clientRegistrationId"; @Getter @@ -193,23 +196,40 @@ public enum RequestMappingMode { @Getter @Setter protected boolean additionalNotNullAnnotations = false; @Setter boolean useHttpServiceProxyFactoryInterfacesConfigurator = false; - @Setter protected boolean autoXSpringPaginated = false; - @Setter protected boolean generateSortValidation = false; - @Setter protected boolean generatePageableConstraintValidation = false; - @Setter protected boolean substituteGenericPagedModel = false; @Getter @Setter protected String clientRegistrationId = null; + @Setter protected boolean useEnumValueInterface = false; + private String valuedEnumClassName = "ValuedEnum"; + + private final SpringPageableSupport pageableSupport = new SpringPageableSupport(); + private final GenericSubstitutionSupport genericSubstitutionSupport = new GenericSubstitutionSupport(); + + // These setters are called by convertPropertyToBooleanAndWriteBack and delegate to the + // shared SpringPageableSupport instance so that all pageable state is in one place. + public void setAutoXSpringPaginated(boolean v) { pageableSupport.setAutoXSpringPaginated(v); } + public void setGenerateSortValidation(boolean v) { pageableSupport.setGenerateSortValidation(v); } + public void setGeneratePageableConstraintValidation(boolean v) { pageableSupport.setGeneratePageableConstraintValidation(v); } + public void setSubstituteGenericPagedModel(boolean v) { pageableSupport.setSubstituteGenericPagedModel(v); } + + // SpringPageableSupport.Context implementation — additional methods not already present + @Override public String getSourceFolder() { return sourceFolder; } + @Override public boolean isUseBeanValidation() { return useBeanValidation; } + @Override public void applySpringdocPageableAnnotation(CodegenOperation op) { + if (DocumentationProvider.SPRINGDOC.equals(getDocumentationProvider())) { + op.imports.add("ParameterObject"); + } + } + + // GenericSubstitutionSupport.Context implementation + @Override public String fileExtension() { return "java"; } + @Override public String toModelImport(String className) { + if (className.contains(".")) return className; // already fully qualified (e.g. from schemaMapping) + return modelPackage() + "." + className; + } + @Override public Map schemaMapping() { return schemaMapping; } - // Map from operationId to allowed sort values for @ValidSort annotation generation - private Map> sortValidationEnums = new HashMap<>(); - // Map from operationId to pageable defaults for @PageableDefault/@SortDefault annotation generation - private Map pageableDefaultsRegistry = new HashMap<>(); - // Map from operationId to pageable constraints for @ValidPageable annotation generation - private Map pageableConstraintsRegistry = new HashMap<>(); - // Map from schema name to detected paged-model info (populated when substituteGenericPagedModel=true) - private Map pagedModelRegistry = new HashMap<>(); - // Simple class name of the PagedModel substitute (derived from importMapping; defaults to "PagedModel") - private String pagedModelClassName = "PagedModel"; + // Holds scan results for Spring Pageable features (populated during preprocessOpenAPI) + private final SpringPageableScanUtils pageableUtils = new SpringPageableScanUtils(); public SpringCodegen() { super(); @@ -366,25 +386,39 @@ public SpringCodegen() { + "When enabled, operations with all three parameters will have Pageable support automatically applied. " + "Operations with x-spring-paginated explicitly set to false will not be auto-detected. " + "Only applies when library=spring-boot.", - autoXSpringPaginated)); + false)); cliOptions.add(CliOption.newBoolean(GENERATE_SORT_VALIDATION, "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.", - generateSortValidation)); + false)); cliOptions.add(CliOption.newBoolean(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "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.", - generatePageableConstraintValidation)); + false)); cliOptions.add(CliOption.newBoolean(SUBSTITUTE_GENERIC_PAGED_MODEL, "Detect schemas that represent paginated responses (an object with a 'content' array property and a 'page' " + "pagination-metadata property) and replace their generated references with " + "PagedModel. By default this uses a generated type in the config package (default 'org.openapitools.configuration'), but `importMappings.PagedModel` can override it to a custom/FQCN-mapped type. The detected page schemas and the pagination metadata " + "schema are suppressed from code generation.", - substituteGenericPagedModel)); + false)); + cliOptions.add(new CliOption(GENERIC_PATTERNS, + "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.")); + cliOptions.add(CliOption.newBoolean(DISCOVER_GENERIC_PATTERNS, + "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)); + cliOptions.add(CliOption.newBoolean(CodegenConstants.USE_ENUM_VALUE_INTERFACE, + CodegenConstants.USE_ENUM_VALUE_INTERFACE_DESC, + useEnumValueInterface)); } @@ -613,6 +647,7 @@ public void processOpts() { convertPropertyToBooleanAndWriteBack(ADDITIONAL_NOT_NULL_ANNOTATIONS, this::setAdditionalNotNullAnnotations); convertPropertyToBooleanAndWriteBack(SUBSTITUTE_GENERIC_PAGED_MODEL, this::setSubstituteGenericPagedModel); + convertPropertyToBooleanAndWriteBack(CodegenConstants.USE_ENUM_VALUE_INTERFACE, this::setUseEnumValueInterface); if (SPRING_BOOT.equals(library)) { convertPropertyToBooleanAndWriteBack(AUTO_X_SPRING_PAGINATED, this::setAutoXSpringPaginated); @@ -620,6 +655,36 @@ public void processOpts() { convertPropertyToBooleanAndWriteBack(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, this::setGeneratePageableConstraintValidation); } + // Parse genericPatterns from additionalProperties + Object rawPatterns = additionalProperties.get(GENERIC_PATTERNS); + if (rawPatterns instanceof List) { + for (Object item : (List) rawPatterns) { + if (item instanceof Map) { + Map map = (Map) item; + GenericPatternConfig cfg = new GenericPatternConfig(); + if (map.get("suffix") instanceof String) cfg.suffix = (String) map.get("suffix"); + if (map.get("prefix") instanceof String) cfg.prefix = (String) map.get("prefix"); + if (map.get("genericClass") instanceof String) cfg.genericClass = (String) map.get("genericClass"); + if (map.get("slot") instanceof String) cfg.slot = (String) map.get("slot"); + if (map.get("slotArray") instanceof String) cfg.slotArray = (String) map.get("slotArray"); + if (map.get("slots") instanceof Map) { + Map slots = new java.util.LinkedHashMap<>(); + ((Map) map.get("slots")).forEach((k, v) -> { + if (k instanceof String && v instanceof String) slots.put((String) k, (String) v); + }); + if (!slots.isEmpty()) cfg.slots = slots; + } + genericSubstitutionSupport.addPattern(cfg); + } + } + } + Object rawDiscover = additionalProperties.get(DISCOVER_GENERIC_PATTERNS); + if (rawDiscover instanceof Boolean) { + genericSubstitutionSupport.setDiscoverGenericPatterns((Boolean) rawDiscover); + } else if ("true".equals(rawDiscover)) { + genericSubstitutionSupport.setDiscoverGenericPatterns(true); + } + // override parent one importMapping.put("JsonDeserialize", (useJackson3 ? JACKSON3_PACKAGE : JACKSON2_PACKAGE) + ".databind.annotation.JsonDeserialize"); @@ -864,55 +929,17 @@ public void preprocessOpenAPI(OpenAPI openAPI) { (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "EnumConverterConfiguration.java")); } - if (SPRING_BOOT.equals(library) && generateSortValidation && useBeanValidation) { - sortValidationEnums = SpringPageableScanUtils.scanSortValidationEnums(openAPI, autoXSpringPaginated); - if (!sortValidationEnums.isEmpty()) { - importMapping.putIfAbsent("ValidSort", configPackage + ".ValidSort"); - supportingFiles.add(new SupportingFile("validSort.mustache", - (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "ValidSort.java")); - } - } + pageableSupport.preprocessOpenAPI(openAPI, this, SPRING_HTTP_INTERFACE, "java"); - if (SPRING_BOOT.equals(library)) { - pageableDefaultsRegistry = SpringPageableScanUtils.scanPageableDefaults(openAPI, autoXSpringPaginated); - if (!pageableDefaultsRegistry.isEmpty()) { - importMapping.putIfAbsent("PageableDefault", "org.springframework.data.web.PageableDefault"); - importMapping.putIfAbsent("SortDefault", "org.springframework.data.web.SortDefault"); - importMapping.putIfAbsent("Sort", "org.springframework.data.domain.Sort"); - } - } + pageableSupport.contributeToGenericSubstitution(genericSubstitutionSupport, this); - if (SPRING_BOOT.equals(library) && generatePageableConstraintValidation && useBeanValidation) { - pageableConstraintsRegistry = SpringPageableScanUtils.scanPageableConstraints(openAPI, autoXSpringPaginated); - if (!pageableConstraintsRegistry.isEmpty()) { - importMapping.putIfAbsent("ValidPageable", configPackage + ".ValidPageable"); - supportingFiles.add(new SupportingFile("validPageable.mustache", - (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "ValidPageable.java")); - } - } + genericSubstitutionSupport.preprocessOpenAPI(openAPI, this); - if (substituteGenericPagedModel) { - pagedModelRegistry = PagedModelScanUtils.scanPagedModels(openAPI); - if (!pagedModelRegistry.isEmpty()) { - boolean customMapping = importMapping.containsKey("PagedModel"); - importMapping.putIfAbsent("PagedModel", configPackage + ".PagedModel"); - if (!customMapping) { - // No custom class provided — generate the simple PagedModel into the config package. - supportingFiles.add(new SupportingFile("pagedModel.mustache", - (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "PagedModel.java")); - } - // Derive the actual simple class name from the FQN in importMapping so that a - // custom mapping (e.g. "PagedModel" → "com.example.MyPagedModel") is respected. - // The simple name of the FQN becomes the token used in generated code, and is - // registered in importMapping so that template import resolution works. - String fqn = importMapping.get("PagedModel"); - pagedModelClassName = fqn.substring(fqn.lastIndexOf('.') + 1); - if (!pagedModelClassName.equals("PagedModel")) { - importMapping.put(pagedModelClassName, fqn); - } - LOGGER.info("substituteGenericPagedModel: detected {} paged-model schema(s): {}", - pagedModelRegistry.size(), pagedModelRegistry.keySet()); - } + if (useEnumValueInterface) { + valuedEnumClassName = EnumValueInterfaceUtils.setupInPreprocessOpenAPI( + importMapping, additionalProperties, supportingFiles, + sourceFolder, configPackage, + "enumValueInterface.mustache", "ValuedEnum.java"); } /* @@ -1243,26 +1270,14 @@ protected boolean isConstructorWithAllArgsAllowed(CodegenModel codegenModel) { public CodegenOperation fromOperation(String path, String httpMethod, Operation operation, List servers) { // Auto-detect pagination parameters and add x-spring-paginated if autoXSpringPaginated is enabled. - // Only for spring-boot; respect manual x-spring-paginated: false override. - if (SPRING_BOOT.equals(library) && autoXSpringPaginated) { - if (operation.getExtensions() == null || !Boolean.FALSE.equals(operation.getExtensions().get("x-spring-paginated"))) { - if (operation.getParameters() != null) { - Set paramNames = operation.getParameters().stream() - .map(io.swagger.v3.oas.models.parameters.Parameter::getName) - .collect(Collectors.toSet()); - if (paramNames.containsAll(Arrays.asList("page", "size", "sort"))) { - if (operation.getExtensions() == null) { - operation.setExtensions(new HashMap<>()); - } - operation.getExtensions().put("x-spring-paginated", Boolean.TRUE); - } - } - } - } - - // add Pageable import only if x-spring-paginated explicitly used - // this allows to use a custom Pageable schema without importing Spring Pageable. - if (Boolean.TRUE.equals(operation.getExtensions().get("x-spring-paginated"))) { + // Must be called before super.fromOperation so the extension is copied to vendorExtensions. + pageableSupport.autoDetectPagination(operation, library); + + // add Pageable import only if x-spring-paginated explicitly used AND it's a server library. + // this allows to use a custom Pageable schema without importing Spring Pageable, + // and avoids polluting the import mapping for client libraries. + if (SPRING_BOOT.equals(library) && operation.getExtensions() != null + && Boolean.TRUE.equals(operation.getExtensions().get("x-spring-paginated"))) { importMapping.put("Pageable", "org.springframework.data.domain.Pageable"); } @@ -1273,68 +1288,9 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation // add org.springframework.format.annotation.DateTimeFormat when needed codegenOperation.allParams.stream().filter(p -> p.isDate || p.isDateTime).findFirst() .ifPresent(p -> codegenOperation.imports.add("DateTimeFormat")); - // add org.springframework.data.domain.Pageable import when needed - if (codegenOperation.vendorExtensions.containsKey("x-spring-paginated")) { - codegenOperation.imports.add("Pageable"); - if (DocumentationProvider.SPRINGDOC.equals(getDocumentationProvider())) { - codegenOperation.imports.add("ParameterObject"); - } - // #8315 Spring Data Web default query params recognized by Pageable - List defaultPageableQueryParams = new ArrayList<>( - Arrays.asList("page", "size", "sort") - ); - - // #8315 Remove matching Spring Data Web default query params if 'x-spring-paginated' with Pageable is used - codegenOperation.queryParams.removeIf(param -> defaultPageableQueryParams.contains(param.baseName)); - codegenOperation.allParams.removeIf(param -> param.isQueryParam && defaultPageableQueryParams.contains(param.baseName)); - - // Build pageable parameter annotations (@ValidPageable, @ValidSort, @PageableDefault, @SortDefault.SortDefaults) - List pageableAnnotations = new ArrayList<>(); - - if (generatePageableConstraintValidation && useBeanValidation && pageableConstraintsRegistry.containsKey(codegenOperation.operationId)) { - SpringPageableScanUtils.PageableConstraintsData constraints = pageableConstraintsRegistry.get(codegenOperation.operationId); - List attrs = new ArrayList<>(); - if (constraints.maxSize >= 0) attrs.add("maxSize = " + constraints.maxSize); - if (constraints.maxPage >= 0) attrs.add("maxPage = " + constraints.maxPage); - pageableAnnotations.add("@ValidPageable(" + String.join(", ", attrs) + ")"); - codegenOperation.imports.add("ValidPageable"); - } - - if (generateSortValidation && useBeanValidation && sortValidationEnums.containsKey(codegenOperation.operationId)) { - List allowedSortValues = sortValidationEnums.get(codegenOperation.operationId); - // Java annotation arrays use {} syntax - String allowedValuesStr = allowedSortValues.stream() - .map(v -> "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\"") - .collect(Collectors.joining(", ")); - pageableAnnotations.add("@ValidSort(allowedValues = {" + allowedValuesStr + "})"); - codegenOperation.imports.add("ValidSort"); - } - - if (pageableDefaultsRegistry.containsKey(codegenOperation.operationId)) { - SpringPageableScanUtils.PageableDefaultsData defaults = pageableDefaultsRegistry.get(codegenOperation.operationId); - if (defaults.page != null || defaults.size != null) { - List attrs = new ArrayList<>(); - if (defaults.page != null) attrs.add("page = " + defaults.page); - if (defaults.size != null) attrs.add("size = " + defaults.size); - pageableAnnotations.add("@PageableDefault(" + String.join(", ", attrs) + ")"); - codegenOperation.imports.add("PageableDefault"); - } - if (!defaults.sortDefaults.isEmpty()) { - // Java annotation arrays use @SortDefault(...) with {} for the sort field array - List sortEntries = defaults.sortDefaults.stream() - .map(sf -> "@SortDefault(sort = {\"" + sf.field + "\"}, direction = Sort.Direction." + sf.direction + ")") - .collect(Collectors.toList()); - pageableAnnotations.add("@SortDefault.SortDefaults({" + String.join(", ", sortEntries) + "})"); - codegenOperation.imports.add("SortDefault"); - codegenOperation.imports.add("Sort"); - } - } - - if (!pageableAnnotations.isEmpty()) { - codegenOperation.vendorExtensions.put("x-pageable-extra-annotation", pageableAnnotations); - } - } + // Build pageable annotations, add Pageable imports, and remove page/size/sort params. + pageableSupport.processPageableAnnotations(codegenOperation, this, "{", "}"); if (codegenOperation.vendorExtensions.containsKey("x-spring-provide-args") && !provideArgsClassSet.isEmpty()) { codegenOperation.imports.addAll(provideArgsClassSet); } @@ -1388,34 +1344,9 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation } // Not an SSE compliant definition } - // If substituteGenericPagedModel is enabled, replace paged-model return types - // with org.springframework.data.web.PagedModel. - if (substituteGenericPagedModel && !pagedModelRegistry.isEmpty() - && codegenOperation.returnBaseType != null) { - PagedModelScanUtils.DetectedPagedModel detected = - pagedModelRegistry.get(codegenOperation.returnBaseType); - if (detected != null) { - String oldType = codegenOperation.returnType; - // Run through toModelName so that schemaMappings (e.g. User → com.example.MyUser) - // are honored: the mapped name is used both in the type arg and for import resolution. - String itemType = toModelName(detected.itemSchemaName); - String newBaseType = pagedModelClassName + "<" + itemType + ">"; - codegenOperation.returnType = newBaseType; - codegenOperation.returnBaseType = pagedModelClassName; - // Clear any container flag — PagedModel is not itself a List/array - codegenOperation.returnContainer = null; - // Add item type import (needed for PagedModel in method signature) - codegenOperation.imports.add(itemType); - codegenOperation.imports.add(pagedModelClassName); - // Remove paged schema import when no annotations are generated — - // the class is suppressed and not referenced anywhere - if (getAnnotationLibrary() == AnnotationLibrary.NONE) { - codegenOperation.imports.remove(detected.schemaName); - } - LOGGER.info("substituteGenericPagedModel: operation '{}': replacing return type '{}' with {}<{}>", - codegenOperation.operationId, oldType, pagedModelClassName, itemType); - } - } + // Replace operation return types for generic schema patterns (genericPatterns feature) + // and substituteGenericPagedModel (paged-model schemas contributed via pre-scan). + genericSubstitutionSupport.substituteReturnType(codegenOperation, this); return codegenOperation; } @@ -1471,49 +1402,16 @@ public Map postProcessAllModels(Map objs) } } - if (substituteGenericPagedModel && !pagedModelRegistry.isEmpty()) { - if (getAnnotationLibrary() == AnnotationLibrary.NONE) { - // No @ApiResponse annotations are generated when annotationLibrary=none, - // so paged schemas are not referenced anywhere → safe to suppress. - Set metaSchemasToCheck = new HashSet<>(); - for (PagedModelScanUtils.DetectedPagedModel detected : pagedModelRegistry.values()) { - if (detected.metaSchemaName != null) { - metaSchemasToCheck.add(detected.metaSchemaName); - } - } - // Remove paged schemas first so reference checks below reflect the post-suppression state. - for (Map.Entry entry : pagedModelRegistry.entrySet()) { - String schemaName = entry.getKey(); - PagedModelScanUtils.DetectedPagedModel detected = entry.getValue(); - if (objs.remove(schemaName) != null) { - LOGGER.info("substituteGenericPagedModel: suppressing model '{}' — replaced by PagedModel<{}>", - schemaName, detected.itemSchemaName); - } - } - // Suppress meta schemas only when no remaining (non-suppressed) schema references them. - // Example: if SearchResult has a 'page: PageMeta' property, PageMeta must be kept. - for (String metaName : metaSchemasToCheck) { - boolean referencedElsewhere = objs.values().stream() - .flatMap(mm -> mm.getModels().stream()) - .map(ModelMap::getModel) - .anyMatch(cm -> cm.imports.contains(metaName)); - if (referencedElsewhere) { - LOGGER.info("substituteGenericPagedModel: keeping pagination metadata model '{}'" - + " — referenced by a non-paged schema", metaName); - } else if (objs.remove(metaName) != null) { - LOGGER.info("substituteGenericPagedModel: suppressing pagination metadata model '{}'" - + " — replaced by PagedModel.PageMetadata", metaName); - } - } - } else { - LOGGER.info("substituteGenericPagedModel: keeping paged-model schemas (annotationLibrary={}) — @ApiResponse annotations reference them", - getAnnotationLibrary().toCliOptValue()); - } - } + objs = genericSubstitutionSupport.suppressGenericSchemas(objs, this); return objs; } + @Override + public void prepareSupportingFile(Map bundle, SupportingFile file) { + genericSubstitutionSupport.prepareSupportingFile(bundle, file); + } + @Override public ModelsMap postProcessModelsEnum(ModelsMap objs) { objs = super.postProcessModelsEnum(objs); @@ -1526,7 +1424,7 @@ public ModelsMap postProcessModelsEnum(ModelsMap objs) { for (CodegenProperty var : cm.vars) { addNullableImports = isAddNullableImports(cm, addNullableImports, var); } - if (Boolean.TRUE.equals(cm.isEnum) && cm.allowableValues != null) { + if (cm.isEnum && cm.allowableValues != null) { cm.imports.add(importMapping.get("JsonValue")); final Map item = new HashMap<>(); item.put("import", importMapping.get("JsonValue")); @@ -1539,6 +1437,12 @@ public ModelsMap postProcessModelsEnum(ModelsMap objs) { } } + if (useEnumValueInterface) { + EnumValueInterfaceUtils.injectInPostProcessModelsEnum( + objs, valuedEnumClassName, importMapping.get("ValuedEnum"), + CodegenConstants.X_IMPLEMENTS); + } + return objs; } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java index 17ceb3757fdb..c4f44ee9832c 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java @@ -22,21 +22,57 @@ import io.swagger.v3.oas.models.PathItem; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.parameters.Parameter; +import org.openapitools.codegen.CodegenOperation; import org.openapitools.codegen.utils.ModelUtils; import java.util.*; import java.util.stream.Collectors; /** - * Language-agnostic utility methods for scanning OpenAPI specs for Spring Pageable-related - * features: sort enum validation, pageable defaults, and pageable constraints (max page/size). + * Utility class for scanning OpenAPI specs for Spring Pageable-related features: + * sort enum validation, pageable defaults, and pageable constraints (max page/size). + * + *

Can be used as a static utility or instantiated to hold the scan results + * ({@link #sortValidationEnums}, {@link #pageableDefaultsRegistry}, + * {@link #pageableConstraintsRegistry}) so that callers do not need to maintain + * those maps themselves. Call {@link #scanAll(OpenAPI, boolean)} once in + * {@code preprocessOpenAPI} to populate them, then access the fields directly.

* *

Used by both kotlin {@link KotlinSpringServerCodegen} and java {@link SpringCodegen} to share - * scan logic. Only the mustache templates and their registration remain language-specific.

+ * scan and annotation-building logic. Only the mustache templates and their registration remain + * language-specific.

*/ -public final class SpringPageableScanUtils { +public class SpringPageableScanUtils { + + public static final String PAGE = "page"; + public static final String SIZE = "size"; + public static final String SORT = "sort"; - private SpringPageableScanUtils() {} + /** + * The three Spring Data Web query-parameter names that together signal a + * {@link org.springframework.data.domain.Pageable} operation: + * {@code page}, {@code size}, and {@code sort}. + * + *

Use this constant instead of repeating {@code Arrays.asList("page", "size", "sort")} + * inline so that all callers stay in sync automatically.

+ */ + public static final List DEFAULT_PAGEABLE_QUERY_PARAMS = + Collections.unmodifiableList(Arrays.asList(PAGE, SIZE, SORT)); + + // ------------------------------------------------------------------------- + // Instance state (populated by scanAll) + // ------------------------------------------------------------------------- + + /** Map from operationId to allowed sort values; populated by {@link #scanAll}. */ + public Map> sortValidationEnums = new HashMap<>(); + + /** Map from operationId to pageable defaults; populated by {@link #scanAll}. */ + public Map pageableDefaultsRegistry = new HashMap<>(); + + /** Map from operationId to pageable constraints; populated by {@link #scanAll}. */ + public Map pageableConstraintsRegistry = new HashMap<>(); + + public SpringPageableScanUtils() {} // ------------------------------------------------------------------------- // Data classes @@ -71,25 +107,68 @@ public boolean hasAny() { } /** - * Carries max constraints for page number and page size from a pageable operation. - * {@code -1} means no constraint specified (no {@code maximum:} in the spec). + * Carries max and min constraints for page number and page size from a pageable operation. + * {@code -1} means no constraint specified (no {@code maximum:}/{@code minimum:} in the spec). */ public static final class PageableConstraintsData { /** Maximum allowed page number, or {@code -1} if unconstrained. */ public final int maxPage; /** Maximum allowed page size, or {@code -1} if unconstrained. */ public final int maxSize; + /** Minimum allowed page number, or {@code -1} if unconstrained. */ + public final int minPage; + /** Minimum allowed page size, or {@code -1} if unconstrained. */ + public final int minSize; - public PageableConstraintsData(int maxPage, int maxSize) { + public PageableConstraintsData(int maxPage, int maxSize, int minPage, int minSize) { this.maxPage = maxPage; this.maxSize = maxSize; + this.minPage = minPage; + this.minSize = minSize; } public boolean hasAny() { - return maxPage >= 0 || maxSize >= 0; + return maxPage >= 0 || maxSize >= 0 || minPage >= 0 || minSize >= 0; } } + // ------------------------------------------------------------------------- + // Instance methods + // ------------------------------------------------------------------------- + + /** + * Populates {@link #sortValidationEnums}, {@link #pageableDefaultsRegistry}, and + * {@link #pageableConstraintsRegistry} by scanning the given OpenAPI document. + * + *

Call this once from {@code preprocessOpenAPI} (guarded by your library check), + * then read the public map fields in {@code fromOperation}.

+ * + * @param openAPI the OpenAPI document to scan + * @param autoXSpringPaginated whether auto-detection of pageable operations is enabled + */ + public void scanAll(OpenAPI openAPI, boolean autoXSpringPaginated) { + sortValidationEnums = scanSortValidationEnums(openAPI, autoXSpringPaginated); + pageableDefaultsRegistry = scanPageableDefaults(openAPI, autoXSpringPaginated); + pageableConstraintsRegistry = scanPageableConstraints(openAPI, autoXSpringPaginated); + } + + /** + * Instance variant of {@link #applyPageableAnnotations(CodegenOperation, boolean, boolean, + * Map, boolean, Map, Map, AnnotationSyntax)} that uses the maps populated by + * {@link #scanAll(OpenAPI, boolean)}. + */ + public void applyPageableAnnotations( + CodegenOperation codegenOperation, + boolean generatePageableConstraintValidation, + boolean useBeanValidation, + boolean generateSortValidation, + AnnotationSyntax syntax) { + applyPageableAnnotations(codegenOperation, + generatePageableConstraintValidation, useBeanValidation, pageableConstraintsRegistry, + generateSortValidation, sortValidationEnums, + pageableDefaultsRegistry, syntax); + } + // ------------------------------------------------------------------------- // Scan methods // ------------------------------------------------------------------------- @@ -101,24 +180,277 @@ public boolean hasAny() { * pagination query parameters (page, size, sort). */ public static boolean willBePageable(Operation operation, boolean autoXSpringPaginated) { - if (operation.getExtensions() != null) { - Object paginated = operation.getExtensions().get("x-spring-paginated"); - if (Boolean.FALSE.equals(paginated)) { - return false; + Boolean xSpringPaginated = getXSpringPaginated(operation); + if (xSpringPaginated != null) { + return xSpringPaginated; + } + if (!autoXSpringPaginated || operation.getParameters() == null) { + return false; + } + Set paramNames = operation.getParameters().stream() + .map(Parameter::getName) + .collect(Collectors.toSet()); + return paramNames.containsAll(DEFAULT_PAGEABLE_QUERY_PARAMS); + } + + /** + * Returns the resolved `x-spring-paginated` flag. + * + * @return `Boolean.TRUE`/`Boolean.FALSE` when explicitly set, otherwise `null` + */ + public static Boolean getXSpringPaginated(Operation operation) { + if (operation.getExtensions() == null) { + return null; + } + Object xSpringPaginated = operation.getExtensions().get("x-spring-paginated"); + if (Boolean.FALSE.equals(xSpringPaginated)) { + return false; + } + if (Boolean.TRUE.equals(xSpringPaginated)) { + return true; + } + return null; + } + + /** + * Auto-detects Pageable pagination query parameters and, when detected, mutates the + * operation by setting {@code x-spring-paginated: true} on its vendor extensions. + * + *

Detection is delegated to {@link #willBePageable(Operation, boolean)}. If the + * operation is already explicitly flagged ({@code x-spring-paginated: true/false}) + * this method is a read-only pass-through — it returns the explicit value without + * mutating extensions. Only auto-detected operations (those whose flag was + * {@code null}) have the extension written.

+ * + *

This method centralises the "detect + mutate" logic shared by both + * {@link SpringCodegen} and {@link KotlinSpringServerCodegen} inside their + * {@code fromOperation} overrides. It must be called before + * {@code super.fromOperation()} so that the base codegen can pick up the extension + * when populating {@code CodegenOperation.vendorExtensions}.

+ * + * @param operation the raw OpenAPI {@link Operation} to inspect (and possibly mutate) + * @param autoXSpringPaginated whether auto-detection is enabled for this generator + * @return {@code true} if the operation is (or was just marked as) paginated + */ + public static boolean applyAutoXSpringPaginatedIfNeeded( + Operation operation, boolean autoXSpringPaginated) { + if (!willBePageable(operation, autoXSpringPaginated)) { + return false; + } + if (getXSpringPaginated(operation) == null) { + if (operation.getExtensions() == null) { + operation.setExtensions(new HashMap<>()); + } + operation.getExtensions().put("x-spring-paginated", Boolean.TRUE); + } + return true; + } + + /** + * Removes the three Spring Data Web default pagination query parameters ({@code page}, + * {@code size}, {@code sort}) from the given codegen operation's parameter lists. + * + *

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

+ * + *

Callers are responsible for ensuring this is only invoked for server-side library + * configurations (spring-boot) where Pageable injection actually takes place.

+ * + * @param codegenOperation the operation whose parameter lists should be pruned + */ + public static void removePageableQueryParams(CodegenOperation codegenOperation) { + codegenOperation.queryParams.removeIf(p -> DEFAULT_PAGEABLE_QUERY_PARAMS.contains(p.baseName)); + codegenOperation.allParams.removeIf(p -> p.isQueryParam && DEFAULT_PAGEABLE_QUERY_PARAMS.contains(p.baseName)); + } + + // ------------------------------------------------------------------------- + // Annotation syntax + // ------------------------------------------------------------------------- + + /** + * Target language annotation syntax for pageable annotations. + * + *

Java and Kotlin differ in how array-valued annotation attributes are written:

+ *
    + *
  • Java uses curly braces: {@code @ValidSort(allowedValues = {"a,asc"})}
  • + *
  • Kotlin uses square brackets: {@code @ValidSort(allowedValues = ["a,asc"])}
  • + *
+ * + *

Additionally, repeated {@code @SortDefault} items inside + * {@code @SortDefault.SortDefaults} differ: + * Java prefixes each with {@code @} and wraps the list in {@code {}}, + * whereas Kotlin omits the {@code @} and passes the items without extra wrapping.

+ */ + public enum AnnotationSyntax { + JAVA, + KOTLIN; + + /** + * Formats {@code content} as an array literal for this language: + * {@code {content}} for Java, {@code [content]} for Kotlin. + */ + public String arrayLiteral(String content) { + return this == JAVA ? "{" + content + "}" : "[" + content + "]"; + } + + /** + * Formats a single {@code @SortDefault} annotation item. + * Java: {@code @SortDefault(innerContent)}, Kotlin: {@code SortDefault(innerContent)}. + */ + public String sortDefaultItem(String innerContent) { + return (this == JAVA ? "@" : "") + "SortDefault(" + innerContent + ")"; + } + + /** + * Formats the argument list inside {@code @SortDefault.SortDefaults(...)}. + * Java wraps with {@code {}}: {@code @SortDefault.SortDefaults({item1, item2})}. + * Kotlin passes items directly: {@code @SortDefault.SortDefaults(item1, item2)}. + */ + public String sortDefaultsArgs(List items) { + String joined = String.join(", ", items); + return this == JAVA ? "{" + joined + "}" : joined; + } + } + + /** + * Builds and attaches the pageable-parameter annotations ({@code @ValidPageable}, + * {@code @ValidSort}, {@code @PageableDefault}, {@code @SortDefault.SortDefaults}) + * to the given codegen operation. + * + *

The annotations and their imports are only added when the relevant feature flags + * ({@code generatePageableConstraintValidation}, {@code generateSortValidation}) are + * enabled and a matching entry exists in the corresponding registry for this + * operation. The result is stored under the + * {@code x-pageable-extra-annotation} vendor extension.

+ * + *

Language-specific differences in annotation array syntax (Java {@code {...}} vs + * Kotlin {@code [...]}) are handled by the {@code syntax} parameter. + * SpringDoc-specific import additions remain in each calling codegen class since + * they differ between Java ({@code ParameterObject}) and Kotlin + * ({@code PageableAsQueryParam} + {@code x-operation-extra-annotation} mutation).

+ * + * @param codegenOperation the operation to annotate + * @param generatePageableConstraintValidation whether to emit {@code @ValidPageable} + * @param useBeanValidation whether bean validation is active + * @param pageableConstraintsRegistry per-operationId constraint data + * @param generateSortValidation whether to emit {@code @ValidSort} + * @param sortValidationEnums per-operationId allowed sort values + * @param pageableDefaultsRegistry per-operationId default page/size/sort data + * @param syntax target language annotation syntax + */ + public static void applyPageableAnnotations( + CodegenOperation codegenOperation, + boolean generatePageableConstraintValidation, + boolean useBeanValidation, + Map pageableConstraintsRegistry, + boolean generateSortValidation, + Map> sortValidationEnums, + Map pageableDefaultsRegistry, + AnnotationSyntax syntax) { + + String operationId = codegenOperation.operationId; + List pageableAnnotations = new ArrayList<>(); + + if (generatePageableConstraintValidation && useBeanValidation + && pageableConstraintsRegistry.containsKey(operationId)) { + PageableConstraintsData constraints = pageableConstraintsRegistry.get(operationId); + List attrs = new ArrayList<>(); + if (constraints.maxSize >= 0) attrs.add("maxSize = " + constraints.maxSize); + if (constraints.maxPage >= 0) attrs.add("maxPage = " + constraints.maxPage); + if (constraints.minSize >= 0) attrs.add("minSize = " + constraints.minSize); + if (constraints.minPage >= 0) attrs.add("minPage = " + constraints.minPage); + pageableAnnotations.add("@ValidPageable(" + String.join(", ", attrs) + ")"); + codegenOperation.imports.add("ValidPageable"); + } + + if (generateSortValidation && useBeanValidation && sortValidationEnums.containsKey(operationId)) { + List allowedSortValues = sortValidationEnums.get(operationId); + String allowedValuesStr = allowedSortValues.stream() + .map(v -> "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\"") + .collect(Collectors.joining(", ")); + pageableAnnotations.add("@ValidSort(allowedValues = " + syntax.arrayLiteral(allowedValuesStr) + ")"); + codegenOperation.imports.add("ValidSort"); + } + + if (pageableDefaultsRegistry.containsKey(operationId)) { + PageableDefaultsData defaults = pageableDefaultsRegistry.get(operationId); + if (defaults.page != null || defaults.size != null) { + List attrs = new ArrayList<>(); + if (defaults.page != null) attrs.add("page = " + defaults.page); + if (defaults.size != null) attrs.add("size = " + defaults.size); + pageableAnnotations.add("@PageableDefault(" + String.join(", ", attrs) + ")"); + codegenOperation.imports.add("PageableDefault"); } - if (Boolean.TRUE.equals(paginated)) { - return true; + if (!defaults.sortDefaults.isEmpty()) { + List sortEntries = defaults.sortDefaults.stream() + .map(sf -> syntax.sortDefaultItem( + "sort = " + syntax.arrayLiteral("\"" + sf.field + "\"") + + ", direction = Sort.Direction." + sf.direction)) + .collect(Collectors.toList()); + pageableAnnotations.add("@SortDefault.SortDefaults(" + syntax.sortDefaultsArgs(sortEntries) + ")"); + codegenOperation.imports.add("SortDefault"); + codegenOperation.imports.add("Sort"); } } - if (autoXSpringPaginated && operation.getParameters() != null) { - Set paramNames = operation.getParameters().stream() - .map(Parameter::getName) - .collect(Collectors.toSet()); - return paramNames.containsAll(Arrays.asList("page", "size", "sort")); + + if (!pageableAnnotations.isEmpty()) { + codegenOperation.vendorExtensions.put("x-pageable-extra-annotation", pageableAnnotations); + } + } + + /** + * Applies SpringDoc-specific annotation handling for a pageable operation. + * + *

When {@code isSpringDoc} is {@code true}:

+ *
    + *
  • Java: adds the {@code ParameterObject} import, which instructs SpringDoc + * to expose the individual pageable query parameters in the OpenAPI UI.
  • + *
  • Kotlin: adds the {@code PageableAsQueryParam} import and prepends + * {@code @PageableAsQueryParam} to the operation's + * {@code x-operation-extra-annotation} vendor extension so the Mustache template + * renders it on the generated controller method.
  • + *
+ * + *

When {@code isSpringDoc} is {@code false} this method is a no-op.

+ * + * @param codegenOperation the operation to annotate + * @param syntax target language annotation syntax + * @param isSpringDoc whether the active documentation provider is SpringDoc + */ + public static void applySpringDocPageableAnnotation( + CodegenOperation codegenOperation, AnnotationSyntax syntax, boolean isSpringDoc) { + if (!isSpringDoc) { + return; + } + if (syntax == AnnotationSyntax.JAVA) { + codegenOperation.imports.add("ParameterObject"); + } else { + codegenOperation.imports.add("PageableAsQueryParam"); + Object existingAnnotation = codegenOperation.vendorExtensions.get("x-operation-extra-annotation"); + List existing = getObjectAsStringList(existingAnnotation); + List updated = new ArrayList<>(); + updated.add("@PageableAsQueryParam"); + updated.addAll(existing); + codegenOperation.vendorExtensions.put("x-operation-extra-annotation", updated); } - return false; } + @SuppressWarnings("unchecked") + private static List getObjectAsStringList(Object object) { + if (object instanceof List) { + return (List) object; + } else if (object instanceof String) { + return Collections.singletonList((String) object); + } + return new ArrayList<>(); + } + + // ------------------------------------------------------------------------- + // Scan methods + // ------------------------------------------------------------------------- + /** * Scans all pageable operations for a {@code sort} parameter with enum values. * @@ -133,14 +465,13 @@ public static Map> scanSortValidationEnums( for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { for (Operation operation : pathEntry.getValue().readOperations()) { String operationId = operation.getOperationId(); - if (operationId == null || !willBePageable(operation, autoXSpringPaginated)) { - continue; - } - if (operation.getParameters() == null) { + if (operationId == null + || operation.getParameters() == null + || !willBePageable(operation, autoXSpringPaginated)) { continue; } for (Parameter param : operation.getParameters()) { - if (!"sort".equals(param.getName())) { + if (!SORT.equals(param.getName())) { continue; } Schema schema = param.getSchema(); @@ -155,9 +486,9 @@ public static Map> scanSortValidationEnums( } // If the top-level schema is an array, the enum lives on its items Schema enumSchema = schema; - if (schema.getItems() != null) { + if (ModelUtils.isArraySchema(schema)) { enumSchema = schema.getItems(); - if (enumSchema.get$ref() != null) { + if (enumSchema != null && enumSchema.get$ref() != null) { enumSchema = ModelUtils.getReferencedSchema(openAPI, enumSchema); } } @@ -179,7 +510,7 @@ public static Map> scanSortValidationEnums( * and {@code sort} parameters. * * @return map from operationId to {@link PageableDefaultsData} (only operations with at - * least one default are included) + * least one default are included) */ public static Map scanPageableDefaults( OpenAPI openAPI, boolean autoXSpringPaginated) { @@ -190,10 +521,9 @@ public static Map scanPageableDefaults( for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { for (Operation operation : pathEntry.getValue().readOperations()) { String operationId = operation.getOperationId(); - if (operationId == null || !willBePageable(operation, autoXSpringPaginated)) { - continue; - } - if (operation.getParameters() == null) { + if (operationId == null + || !willBePageable(operation, autoXSpringPaginated) + || operation.getParameters() == null) { continue; } Integer pageDefault = null; @@ -213,17 +543,17 @@ public static Map scanPageableDefaults( } Object defaultValue = schema.getDefault(); switch (param.getName()) { - case "page": + case PAGE: if (defaultValue instanceof Number) { pageDefault = ((Number) defaultValue).intValue(); } break; - case "size": + case SIZE: if (defaultValue instanceof Number) { sizeDefault = ((Number) defaultValue).intValue(); } break; - case "sort": + case SORT: List sortValues = new ArrayList<>(); if (defaultValue instanceof String) { sortValues.add((String) defaultValue); @@ -256,11 +586,12 @@ public static Map scanPageableDefaults( } /** - * Scans all pageable operations for {@code maximum:} constraints on {@code page} and - * {@code size} parameters. + * Scans all pageable operations for {@code maximum:} and {@code minimum:} constraints on + * {@code page} and {@code size} parameters. Values are resolved through {@code allOf} and + * {@code $ref} schemas so that constraints defined on shared component schemas are honoured. * * @return map from operationId to {@link PageableConstraintsData} (only operations with - * at least one {@code maximum:} constraint are included) + * at least one constraint are included) */ public static Map scanPageableConstraints( OpenAPI openAPI, boolean autoXSpringPaginated) { @@ -271,38 +602,44 @@ public static Map scanPageableConstraints( for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { for (Operation operation : pathEntry.getValue().readOperations()) { String operationId = operation.getOperationId(); - if (operationId == null || !willBePageable(operation, autoXSpringPaginated)) { - continue; - } - if (operation.getParameters() == null) { + if (operationId == null + || !willBePageable(operation, autoXSpringPaginated) + || operation.getParameters() == null) { continue; } int maxPage = -1; int maxSize = -1; + int minPage = -1; + int minSize = -1; for (Parameter param : operation.getParameters()) { Schema schema = param.getSchema(); if (schema == null) { continue; } - if (schema.get$ref() != null) { - schema = ModelUtils.getReferencedSchema(openAPI, schema); - } - if (schema == null || schema.getMaximum() == null) { - continue; - } - int maximum = schema.getMaximum().intValue(); + ModelUtils.ResolvedMaxBound maxBound = ModelUtils.resolveMaximumBound(openAPI, schema); + ModelUtils.ResolvedMinBound minBound = ModelUtils.resolveMinimumBound(openAPI, schema); switch (param.getName()) { - case "page": - maxPage = maximum; + case PAGE: + if (maxBound != null) { + maxPage = toIntInclusiveMax(maxBound); + } + if (minBound != null) { + minPage = toIntInclusiveMin(minBound); + } break; - case "size": - maxSize = maximum; + case SIZE: + if (maxBound != null) { + maxSize = toIntInclusiveMax(maxBound); + } + if (minBound != null) { + minSize = toIntInclusiveMin(minBound); + } break; default: break; } } - PageableConstraintsData data = new PageableConstraintsData(maxPage, maxSize); + PageableConstraintsData data = new PageableConstraintsData(maxPage, maxSize, minPage, minSize); if (data.hasAny()) { result.put(operationId, data); } @@ -310,4 +647,20 @@ public static Map scanPageableConstraints( } return result; } + + private static Integer toIntInclusiveMax(ModelUtils.ResolvedMaxBound maxBound) { + if (maxBound == null) { + return null; + } + return maxBound.exclusive ? maxBound.maxBound.intValue() - 1 : maxBound.maxBound.intValue(); + } + + private static Integer toIntInclusiveMin(ModelUtils.ResolvedMinBound minBound) { + if (minBound == null) { + return null; + } + return minBound.exclusive ? minBound.minBound.intValue() + 1 : minBound.minBound.intValue(); + } + + } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableSupport.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableSupport.java new file mode 100644 index 000000000000..eb02433e3897 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableSupport.java @@ -0,0 +1,400 @@ +/* + * 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 io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.parameters.Parameter; +import lombok.Getter; +import lombok.Setter; +import org.openapitools.codegen.CodegenOperation; +import org.openapitools.codegen.SupportingFile; +import org.openapitools.codegen.languages.features.DocumentationProviderFeatures.AnnotationLibrary; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Stateful delegate that centralises all Spring Pageable / PagedModel logic shared by + * {@link SpringCodegen} (java-spring) and {@link KotlinSpringServerCodegen} (kotlin-spring). + * + *

Because those two generators extend different base classes + * ({@code AbstractJavaCodegen} and {@code AbstractKotlinCodegen}), a shared abstract class + * is not possible. Instead, each generator holds one instance of this class and delegates + * the three lifecycle phases ({@code preprocessOpenAPI}, {@code fromOperation}, + * {@code postProcessAllModels}) to it via the inner {@link Context} interface.

+ * + *

Language-specific variations (file extension, annotation-array brackets, HTTP-interface + * library name) are passed as parameters at call sites, keeping this class free of any + * language-specific logic.

+ * + *

Relationship to {@link GenericSubstitutionSupport}

+ *

Substitution and suppression of paged-model schemas is delegated to + * {@link GenericSubstitutionSupport}, which is the single code path shared with the + * name-based {@code genericPatterns} feature. {@link #contributeToGenericSubstitution} + * converts each structurally-detected {@link PagedModelScanUtils.DetectedPagedModel} into a + * {@link GenericSchemaScanUtils.GenericInstance} and registers it (alongside the raw name + * of the companion {@code PageMetadata}-style schema) so downstream return-type substitution, + * property substitution, and meta-schema suppression all happen uniformly.

+ * + *

Wiring order in the generator's {@code preprocessOpenAPI}:

+ *
    + *
  1. {@link #preprocessOpenAPI} (this class) — scans pageable features.
  2. + *
  3. {@link #contributeToGenericSubstitution} — feeds detected paged models into the + * generic substitution registry.
  4. + *
  5. {@link GenericSubstitutionSupport#preprocessOpenAPI} — runs tier-1/tier-2 generic + * pattern scanning (vendor extensions override pre-scanned entries; tier-2 skips them).
  6. + *
+ */ +public final class SpringPageableSupport { + + private final Logger LOGGER = LoggerFactory.getLogger(SpringPageableSupport.class); + + // ------------------------------------------------------------------------- + // Context interface — implemented by each generator to expose its internals + // ------------------------------------------------------------------------- + + /** + * Narrow callback interface that gives {@link SpringPageableSupport} read/write access + * to the generator's configuration without requiring a specific base class. + */ + public interface Context { + /** Returns the active library name (e.g. {@code "spring-boot"}). */ + String getLibrary(); + + /** Returns the config package, e.g. {@code "org.openapitools.configuration"}. */ + String getConfigPackage(); + + /** Returns the source folder path used to locate generated files. */ + String getSourceFolder(); + + /** Returns whether bean-validation annotations are enabled. */ + boolean isUseBeanValidation(); + + /** 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. + * Callers may add entries directly. + */ + List supportingFiles(); + + /** + * Called when {@code x-spring-paginated} is active and Spring-doc is the documentation + * provider. Each language adds its own Springdoc Pageable annotation to the operation. + * + *

Java adds {@code ParameterObject}; Kotlin adds {@code @PageableAsQueryParam} to + * {@code x-operation-extra-annotation}.

+ */ + void applySpringdocPageableAnnotation(CodegenOperation op); + } + + // ------------------------------------------------------------------------- + // Feature flags (mirrored from each generator via setters) + // ------------------------------------------------------------------------- + + @Getter @Setter private boolean autoXSpringPaginated = false; + @Getter @Setter private boolean generateSortValidation = false; + @Getter @Setter private boolean generatePageableConstraintValidation = false; + @Getter @Setter private boolean substituteGenericPagedModel = false; + + // ------------------------------------------------------------------------- + // State registries (populated in preprocessOpenAPI, consumed later) + // ------------------------------------------------------------------------- + + /** operationId → allowed sort values for {@code @ValidSort} generation */ + private Map> sortValidationEnums = new HashMap<>(); + + /** operationId → page/size/sort defaults for {@code @PageableDefault}/{@code @SortDefault} generation */ + private Map pageableDefaultsRegistry = new HashMap<>(); + + /** operationId → max page/size constraints for {@code @ValidPageable} generation */ + private Map pageableConstraintsRegistry = new HashMap<>(); + + /** schemaName → detected paged-model info, for return-type substitution and schema suppression */ + private Map pagedModelRegistry = new HashMap<>(); + + /** Simple class name derived from importMapping; defaults to {@code "PagedModel"} */ + @Getter private String pagedModelClassName = "PagedModel"; + + // ------------------------------------------------------------------------- + // Lifecycle methods + // ------------------------------------------------------------------------- + + /** + * Scans the OpenAPI spec for pageable features and configures supporting files and + * import mappings accordingly. + * + *

Call this from your generator's {@code preprocessOpenAPI} override.

+ * + * @param openAPI the OpenAPI model + * @param ctx callback access to the generator's state + * @param httpInterfaceLibrary the library name used for the HTTP-interface variant + * (e.g. {@code "spring-http-interface"} for Java, + * {@code "spring-declarative-http-interface"} for Kotlin) + * @param fileExtension language file extension without dot (e.g. {@code "java"} or {@code "kt"}) + */ + public void preprocessOpenAPI(OpenAPI openAPI, Context ctx, + String httpInterfaceLibrary, String fileExtension) { + String library = ctx.getLibrary(); + String configPath = (ctx.getSourceFolder() + File.separator + ctx.getConfigPackage()) + .replace(".", File.separator); + + if (SpringCodegen.SPRING_BOOT.equals(library) && generateSortValidation && ctx.isUseBeanValidation()) { + sortValidationEnums = SpringPageableScanUtils.scanSortValidationEnums(openAPI, autoXSpringPaginated); + if (!sortValidationEnums.isEmpty()) { + ctx.importMapping().putIfAbsent("ValidSort", ctx.getConfigPackage() + ".ValidSort"); + ctx.supportingFiles().add(new SupportingFile("validSort.mustache", + configPath, "ValidSort." + fileExtension)); + } + } + + if (SpringCodegen.SPRING_BOOT.equals(library)) { + pageableDefaultsRegistry = SpringPageableScanUtils.scanPageableDefaults(openAPI, autoXSpringPaginated); + if (!pageableDefaultsRegistry.isEmpty()) { + ctx.importMapping().putIfAbsent("PageableDefault", "org.springframework.data.web.PageableDefault"); + ctx.importMapping().putIfAbsent("SortDefault", "org.springframework.data.web.SortDefault"); + ctx.importMapping().putIfAbsent("Sort", "org.springframework.data.domain.Sort"); + } + } + + if (SpringCodegen.SPRING_BOOT.equals(library) && generatePageableConstraintValidation && ctx.isUseBeanValidation()) { + pageableConstraintsRegistry = SpringPageableScanUtils.scanPageableConstraints(openAPI, autoXSpringPaginated); + if (!pageableConstraintsRegistry.isEmpty()) { + ctx.importMapping().putIfAbsent("ValidPageable", ctx.getConfigPackage() + ".ValidPageable"); + ctx.supportingFiles().add(new SupportingFile("validPageable.mustache", + configPath, "ValidPageable." + fileExtension)); + } + } + + if ((SpringCodegen.SPRING_BOOT.equals(library) || httpInterfaceLibrary.equals(library) + || SpringCodegen.SPRING_CLOUD_LIBRARY.equals(library)) + && substituteGenericPagedModel) { + pagedModelRegistry = PagedModelScanUtils.scanPagedModels(openAPI); + if (!pagedModelRegistry.isEmpty()) { + boolean customMapping = ctx.importMapping().containsKey("PagedModel"); + ctx.importMapping().putIfAbsent("PagedModel", ctx.getConfigPackage() + ".PagedModel"); + if (!customMapping) { + ctx.supportingFiles().add(new SupportingFile("pagedModel.mustache", + configPath, "PagedModel." + fileExtension)); + } + String fqn = ctx.importMapping().get("PagedModel"); + pagedModelClassName = fqn.substring(fqn.lastIndexOf('.') + 1); + if (!pagedModelClassName.equals("PagedModel")) { + ctx.importMapping().putIfAbsent(pagedModelClassName, fqn); + } + LOGGER.info("substituteGenericPagedModel: detected {} paged-model schema(s): {}", + pagedModelRegistry.size(), pagedModelRegistry.keySet()); + } + } + } + + /** + * Contributes structurally-detected paged-model schemas to {@link GenericSubstitutionSupport} + * as pre-scanned {@link GenericSchemaScanUtils.GenericInstance}s. + * + *

Call this in the generator's {@code preprocessOpenAPI}, after + * {@link #preprocessOpenAPI} (this class) and before + * {@link GenericSubstitutionSupport#preprocessOpenAPI}. That ordering ensures:

+ *
    + *
  • Pre-scanned pageable instances are in the registry before tier-1 vendor-extension + * scanning — vendor extensions therefore override structural detection.
  • + *
  • The pre-scanned schemas are included in the {@code tier1Names} exclusion set, so + * tier-2 pattern scanning will not produce duplicate entries for them.
  • + *
  • The {@link GenericSubstitutionSupport} re-keying step (via {@code toModelName()}) + * applies to the pre-scanned entries too, making them resilient to + * {@code modelNameSuffix} / {@code modelNamePrefix}.
  • + *
+ * + *

No-op when {@code substituteGenericPagedModel} is disabled or no paged models + * were detected.

+ * + * @param genericSupport the {@link GenericSubstitutionSupport} delegate to populate + * @param ctx callback access to the generator's state + */ + public void contributeToGenericSubstitution(GenericSubstitutionSupport genericSupport, + Context ctx) { + if (!substituteGenericPagedModel || pagedModelRegistry.isEmpty()) { + return; + } + String pagedModelFqn = ctx.importMapping().get(pagedModelClassName); + for (PagedModelScanUtils.DetectedPagedModel d : pagedModelRegistry.values()) { + Map typeArgs = new LinkedHashMap<>(); + typeArgs.put("content", d.itemSchemaName); // raw name; toModelName() applied by GenericSubstitutionSupport + Map slotTypeParams = new LinkedHashMap<>(); + slotTypeParams.put("content", "T"); + GenericSchemaScanUtils.GenericInstance inst = new GenericSchemaScanUtils.GenericInstance( + d.rawSchemaName, // raw spec name; re-keyed by preprocessOpenAPI + pagedModelClassName, // e.g. "PagedModel" or custom class simple name + pagedModelFqn, // FQN already registered in importMapping + false, // Mode A — external / supporting-file class + typeArgs, + slotTypeParams, + "content", // slot property name + false, // slotIsArray is only consumed by Mode B class generation + Collections.emptyList() + ); + genericSupport.addPreScannedInstance(inst, d.rawMetaSchemaName); + } + } + + /** + * Auto-detects pagination parameters ({@code page}, {@code size}, {@code sort}) on the + * raw operation and marks it with {@code x-spring-paginated: true} if eligible. + * + *

Must be called before {@code super.fromOperation()} so that the extension + * is copied to {@code codegenOperation.vendorExtensions} by the base class.

+ * + *

Respects a manual {@code x-spring-paginated: false} override in the spec.

+ * + * @param operation the raw OpenAPI operation + * @param library the active library name + */ + public void autoDetectPagination(Operation operation, String library) { + if (!SpringCodegen.SPRING_BOOT.equals(library) || !autoXSpringPaginated) { + return; + } + if (operation.getExtensions() != null + && Boolean.FALSE.equals(operation.getExtensions().get("x-spring-paginated"))) { + return; + } + if (operation.getParameters() != null) { + Set paramNames = operation.getParameters().stream() + .map(Parameter::getName) + .collect(Collectors.toSet()); + if (paramNames.containsAll(Arrays.asList("page", "size", "sort"))) { + if (operation.getExtensions() == null) { + operation.setExtensions(new HashMap<>()); + } + operation.getExtensions().put("x-spring-paginated", Boolean.TRUE); + } + } + } + + /** + * Processes a {@code x-spring-paginated} operation: adds Pageable imports, builds + * pageable parameter annotations, and removes the {@code page}/{@code size}/{@code sort} + * query parameters. + * + *

Must be called after {@code super.fromOperation()} so that + * {@code codegenOperation.vendorExtensions} is populated. This method is a no-op when + * {@code x-spring-paginated} is not present or the library is not {@code spring-boot}.

+ * + * @param codegenOperation the codegen operation to annotate + * @param ctx callback access to the generator's state + * @param arrayOpen opening bracket for annotation arrays: + * {@code "{"} for Java, {@code "["} for Kotlin + * @param arrayClose closing bracket for annotation arrays: + * {@code "}"} for Java, {@code "]"} for Kotlin + */ + public void processPageableAnnotations(CodegenOperation codegenOperation, Context ctx, + String arrayOpen, String arrayClose) { + if (!SpringCodegen.SPRING_BOOT.equals(ctx.getLibrary())) { + // For client libraries (spring-cloud, spring-declarative-http-interface) + // x-spring-paginated is not supported: they need explicit query parameters for HTTP + // calls, not a Pageable object. Strip the extension so the template does not render + // Pageable. The individual page/size/sort query parameters declared in the spec are + // preserved untouched. + if (codegenOperation.vendorExtensions.remove("x-spring-paginated") != null) { + LOGGER.debug("x-spring-paginated on operation '{}' is ignored for library '{}'; " + + "Pageable is only supported for spring-boot. " + + "Individual page/size/sort query parameters will be used instead.", + codegenOperation.operationId, ctx.getLibrary()); + } + return; + } + if (!Boolean.TRUE.equals(codegenOperation.vendorExtensions.get("x-spring-paginated"))) { + return; + } + + ctx.importMapping().putIfAbsent("Pageable", "org.springframework.data.domain.Pageable"); + codegenOperation.imports.add("Pageable"); + ctx.applySpringdocPageableAnnotation(codegenOperation); + + List defaultPageableQueryParams = Arrays.asList("page", "size", "sort"); + codegenOperation.queryParams.removeIf(p -> defaultPageableQueryParams.contains(p.baseName)); + codegenOperation.allParams.removeIf(p -> p.isQueryParam && defaultPageableQueryParams.contains(p.baseName)); + + List pageableAnnotations = new ArrayList<>(); + + if (generatePageableConstraintValidation && ctx.isUseBeanValidation() + && pageableConstraintsRegistry.containsKey(codegenOperation.operationId)) { + SpringPageableScanUtils.PageableConstraintsData constraints = + pageableConstraintsRegistry.get(codegenOperation.operationId); + List attrs = new ArrayList<>(); + if (constraints.maxSize >= 0) attrs.add("maxSize = " + constraints.maxSize); + if (constraints.maxPage >= 0) attrs.add("maxPage = " + constraints.maxPage); + if (constraints.minSize >= 0) attrs.add("minSize = " + constraints.minSize); + if (constraints.minPage >= 0) attrs.add("minPage = " + constraints.minPage); + pageableAnnotations.add("@ValidPageable(" + String.join(", ", attrs) + ")"); + codegenOperation.imports.add("ValidPageable"); + } + + if (generateSortValidation && ctx.isUseBeanValidation() + && sortValidationEnums.containsKey(codegenOperation.operationId)) { + List allowedSortValues = sortValidationEnums.get(codegenOperation.operationId); + String allowedValuesStr = allowedSortValues.stream() + .map(v -> "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\"") + .collect(Collectors.joining(", ")); + pageableAnnotations.add("@ValidSort(allowedValues = " + arrayOpen + allowedValuesStr + arrayClose + ")"); + codegenOperation.imports.add("ValidSort"); + } + + if (pageableDefaultsRegistry.containsKey(codegenOperation.operationId)) { + SpringPageableScanUtils.PageableDefaultsData defaults = + pageableDefaultsRegistry.get(codegenOperation.operationId); + if (defaults.page != null || defaults.size != null) { + List attrs = new ArrayList<>(); + if (defaults.page != null) attrs.add("page = " + defaults.page); + if (defaults.size != null) attrs.add("size = " + defaults.size); + pageableAnnotations.add("@PageableDefault(" + String.join(", ", attrs) + ")"); + codegenOperation.imports.add("PageableDefault"); + } + if (!defaults.sortDefaults.isEmpty()) { + // Java uses @SortDefault(sort = {...}); Kotlin uses SortDefault(sort = [...]) + String sortAnnotationPrefix = "{".equals(arrayOpen) ? "@SortDefault" : "SortDefault"; + List sortEntries = defaults.sortDefaults.stream() + .map(sf -> sortAnnotationPrefix + "(sort = " + arrayOpen + "\"" + sf.field + "\"" + + arrayClose + ", direction = Sort.Direction." + sf.direction + ")") + .collect(Collectors.toList()); + pageableAnnotations.add("@SortDefault.SortDefaults(" + + ("{".equals(arrayOpen) ? arrayOpen + String.join(", ", sortEntries) + arrayClose + : String.join(", ", sortEntries)) + + ")"); + codegenOperation.imports.add("SortDefault"); + codegenOperation.imports.add("Sort"); + } + } + + if (!pageableAnnotations.isEmpty()) { + codegenOperation.vendorExtensions.put("x-pageable-extra-annotation", pageableAnnotations); + } + } +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index 88346d9046f7..0ef71644e63f 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -36,6 +36,8 @@ import io.swagger.v3.parser.util.SchemaTypeUtil; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.openapitools.codegen.CodegenConfig; import org.openapitools.codegen.CodegenModel; import org.openapitools.codegen.IJsonSchemaValidationProperties; @@ -581,7 +583,7 @@ public static boolean isMapSchema(Schema schema) { // additionalProperties explicitly set to false if ((schema.getAdditionalProperties() instanceof Boolean && Boolean.FALSE.equals(schema.getAdditionalProperties())) || - (schema.getAdditionalProperties() instanceof Schema && Boolean.FALSE.equals(((Schema) schema.getAdditionalProperties()).getBooleanSchemaValue())) + (schema.getAdditionalProperties() instanceof Schema && Boolean.FALSE.equals(((Schema) schema.getAdditionalProperties()).getBooleanSchemaValue())) ) { return false; } @@ -718,13 +720,13 @@ public static boolean isDateTimeSchema(Schema schema) { public static boolean isDateTimeLocalSchema(Schema schema) { // format: date-time-local, see https://spec.openapis.org/registry/format/date-time-local.html return (SchemaTypeUtil.STRING_TYPE.equals(getType(schema)) - && "date-time-local".equals(schema.getFormat())); + && "date-time-local".equals(schema.getFormat())); } public static boolean isTimeLocalSchema(Schema schema) { // format: time-local, see https://spec.openapis.org/registry/format/time-local.html return (SchemaTypeUtil.STRING_TYPE.equals(getType(schema)) - && "time-local".equals(schema.getFormat())); + && "time-local".equals(schema.getFormat())); } public static boolean isPasswordSchema(Schema schema) { @@ -843,15 +845,181 @@ public static boolean isModelWithPropertiesOnly(Schema schema) { (null != schema.getProperties() && !schema.getProperties().isEmpty()) && // no additionalProperties is set (schema.getAdditionalProperties() == null || - // additionalProperties is boolean and set to false - (schema.getAdditionalProperties() instanceof Boolean && !(Boolean) schema.getAdditionalProperties()) || - // additionalProperties is a schema with its boolean value set to false - (schema.getAdditionalProperties() instanceof Schema && - ((Schema) schema.getAdditionalProperties()).getBooleanSchemaValue() != null && - !((Schema) schema.getAdditionalProperties()).getBooleanSchemaValue()) + // additionalProperties is boolean and set to false + (schema.getAdditionalProperties() instanceof Boolean && !(Boolean) schema.getAdditionalProperties()) || + // additionalProperties is a schema with its boolean value set to false + (schema.getAdditionalProperties() instanceof Schema && + ((Schema) schema.getAdditionalProperties()).getBooleanSchemaValue() != null && + !((Schema) schema.getAdditionalProperties()).getBooleanSchemaValue()) ); } + public static final class ResolvedMaxBound implements Comparable { + + public final BigDecimal maxBound; + public final boolean exclusive; + + private ResolvedMaxBound(BigDecimal maxBound, boolean exclusive) { + this.maxBound = maxBound; + this.exclusive = exclusive; + } + + @Nullable + public static ResolvedMaxBound getSmallerMaxBound(@Nullable ResolvedMaxBound first, @Nullable ResolvedMaxBound second) { + if (first == null && second == null) { + return null; + } + if (first != null && second != null) { + boolean firstIsSmallerOrSame = first.compareTo(second) <= 0; + return firstIsSmallerOrSame ? first : second; + } + if (second == null) { + return first; + } + return second; + } + + @Nullable + public static ResolvedMaxBound createResolvedMaxBound(@Nullable BigDecimal maxBound, boolean exclusive) { + return maxBound == null ? null : new ResolvedMaxBound(maxBound, exclusive); + } + + @Override + public int compareTo(@NonNull ResolvedMaxBound o) { + // lower maximum is lower + int comparison = this.maxBound.compareTo(o.maxBound); + if (comparison == 0) { + // if they are identical, then the one with exclusive is lower maximum + return Boolean.compare(o.exclusive, this.exclusive); + } + return comparison; + } + } + + public static final class ResolvedMinBound implements Comparable { + + public final BigDecimal minBound; + public final boolean exclusive; + + private ResolvedMinBound(BigDecimal minBound, boolean exclusive) { + this.minBound = minBound; + this.exclusive = exclusive; + } + + @Nullable + public static ResolvedMinBound getLargerMinBound(@Nullable ResolvedMinBound first, @Nullable ResolvedMinBound second) { + if (first == null && second == null) { + return null; + } + if (first != null && second != null) { + boolean firstIsLargerOrSame = first.compareTo(second) >= 0; + return firstIsLargerOrSame ? first : second; + } + if (second == null) { + return first; + } + return second; + } + + @Nullable + public static ResolvedMinBound createResolvedMinBound(@Nullable BigDecimal minBound, boolean exclusive) { + return minBound == null ? null : new ResolvedMinBound(minBound, exclusive); + } + + @Override + public int compareTo(@NonNull ResolvedMinBound o) { + //lower minimum is lower + int comparison = this.minBound.compareTo(o.minBound); + // if they are identical, then the one without exclusive is lower minimum + if (comparison == 0) { + return Boolean.compare(this.exclusive, o.exclusive); + } + return comparison; + } + } + + /** + * Extracts the effective maximum bound from a single (non-allOf, already-dereferenced) schema, + * taking both OAS 3.0 boolean {@code exclusiveMaximum} and OAS 3.1 numeric + * {@code exclusiveMaximum} into account. + */ + @Nullable + private static ResolvedMaxBound extractMaxBound(Schema schema) { + return ResolvedMaxBound.getSmallerMaxBound( + // 3.0 - 3.1 maximum (with 3.0 possible exclusive) + ResolvedMaxBound.createResolvedMaxBound(schema.getMaximum(), Boolean.TRUE.equals(schema.getExclusiveMaximum())), + // 3.1 exclusive maximum + ResolvedMaxBound.createResolvedMaxBound(schema.getExclusiveMaximumValue(), true) + ); + } + + /** + * Extracts the effective minimum bound from a single (non-allOf, already-dereferenced) schema, + * taking both OAS 3.0 boolean {@code exclusiveMinimum} and OAS 3.1 numeric + * {@code exclusiveMinimum} into account. + */ + @Nullable + private static ResolvedMinBound extractMinBound(Schema schema) { + return ResolvedMinBound.getLargerMinBound( + // 3.0 - 3.1 minimum (with 3.0 possible exclusive) + ResolvedMinBound.createResolvedMinBound(schema.getMinimum(), Boolean.TRUE.equals(schema.getExclusiveMinimum())), + // 3.1 exclusive minimum + ResolvedMinBound.createResolvedMinBound(schema.getExclusiveMinimumValue(), true) + ); + } + + /** + * Returns the effective {@code maximum} for the given schema as a {@link ResolvedMaxBound}, + * resolving through a top-level {@code $ref} and walking any {@code allOf} items. + * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive + * (smallest) value wins. When two bounds share the same value, the exclusive one wins. + * Both OAS 3.0 boolean {@code exclusiveMaximum} and OAS 3.1 numeric {@code exclusiveMaximum} + * are taken into account. + * + * @param openAPI the OpenAPI document used to resolve {@code $ref}s + * @param schema the schema to inspect + * @return the effective maximum bound, or {@code null} if none is defined + */ + @Nullable + public static ResolvedMaxBound resolveMaximumBound(OpenAPI openAPI, Schema schema) { + schema = getReferencedSchema(openAPI, schema); + if (schema == null) return null; + + ResolvedMaxBound result = extractMaxBound(schema); + return !hasAllOf(schema) + ? result + : schema.getAllOf().stream() + // recursive search for smallest max bound + .map(allOfItem -> resolveMaximumBound(openAPI, allOfItem)) + .reduce(result, ResolvedMaxBound::getSmallerMaxBound); + } + + /** + * Returns the effective {@code minimum} for the given schema as a {@link ResolvedMinBound}, + * resolving through a top-level {@code $ref} and walking any {@code allOf} items. + * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive + * (largest) value wins. When two bounds share the same value, the exclusive one wins. + * Both OAS 3.0 boolean {@code exclusiveMinimum} and OAS 3.1 numeric {@code exclusiveMinimum} + * are taken into account. + * + * @param openAPI the OpenAPI document used to resolve {@code $ref}s + * @param schema the schema to inspect + * @return the effective minimum bound, or {@code null} if none is defined + */ + @Nullable + public static ResolvedMinBound resolveMinimumBound(OpenAPI openAPI, Schema schema) { + schema = getReferencedSchema(openAPI, schema); + if (schema == null) return null; + + ResolvedMinBound result = extractMinBound(schema); + return !hasAllOf(schema) + ? result + : schema.getAllOf().stream() + // recursive search for largest min bound + .map(allOfItem -> resolveMinimumBound(openAPI, allOfItem)) + .reduce(result, ResolvedMinBound::getLargerMinBound); + } + public static boolean hasValidation(Schema sc) { return ( sc.getMaxItems() != null || @@ -2318,8 +2486,8 @@ public static Schema cloneSchema(Schema schema, boolean openapi31) { /** * Simplifies the schema by removing the oneOfAnyOf if the oneOfAnyOf only contains a single non-null sub-schema * - * @param openAPI OpenAPI - * @param schema Schema + * @param openAPI OpenAPI + * @param schema Schema * @param subSchemas The oneOf or AnyOf schemas * @return The simplified schema */ @@ -2463,8 +2631,8 @@ public static boolean isUnsupportedSchema(OpenAPI openAPI, Schema schema) { /** * Copy meta data (e.g. description, default, examples, etc) from one schema to another. * - * @param from From schema - * @param to To schema + * @param from From schema + * @param to To schema */ public static void copyMetadata(Schema from, Schema to) { if (from.getDescription() != null) { @@ -2544,8 +2712,9 @@ public static boolean isMetadataOnlySchema(Schema schema) { /** * Returns true if the OpenAPI specification contains any schemas which are enums. - * @param openAPI OpenAPI specification - * @return true if the OpenAPI specification contains any schemas which are enums. + * + * @param openAPI OpenAPI specification + * @return true if the OpenAPI specification contains any schemas which are enums. */ public static boolean containsEnums(OpenAPI openAPI) { Map schemaMap = getSchemas(openAPI); diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/enumValueInterface.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/enumValueInterface.mustache new file mode 100644 index 000000000000..7f16c7a03146 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/JavaSpring/enumValueInterface.mustache @@ -0,0 +1,6 @@ +package {{configPackage}}; + +{{>generatedAnnotation}} +public interface ValuedEnum { + T getValue(); +} diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/genericClass.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/genericClass.mustache new file mode 100644 index 000000000000..d49316c9d6cd --- /dev/null +++ b/modules/openapi-generator/src/main/resources/JavaSpring/genericClass.mustache @@ -0,0 +1,26 @@ +package {{configPackage}}; +{{#genericClassDef}}{{#needsList}} +import java.util.List; +{{/needsList}} +/** + * Generic class generated by openapi-generator from schema pattern '{{className}}'. + * Type parameters correspond to the varying domain types. + * + *

To use your own class instead, supply a fully-qualified class name via + * {@code importMappings.{{className}}} in the generator config. + */ +public class {{className}}<{{#typeParams}}{{typeParam}}{{^isLast}}, {{/isLast}}{{/typeParams}}> { +{{#properties}} + private {{javaType}} {{name}}; +{{/properties}} + public {{className}}() {} +{{#properties}} + public {{javaType}} get{{capitalName}}() { return {{name}}; } + + public {{className}}<{{#typeParams}}{{typeParam}}{{^isLast}}, {{/isLast}}{{/typeParams}}> set{{capitalName}}({{javaType}} {{name}}) { + this.{{name}} = {{name}}; + return this; + } +{{/properties}} +} +{{/genericClassDef}} diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/validPageable.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/validPageable.mustache index daf547481640..7cb93f4b6bf6 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/validPageable.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/validPageable.mustache @@ -13,13 +13,15 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Validates that the page number and page size in the annotated {@link Pageable} parameter do not - * exceed their configured maximums. + * Validates that the page number and page size in the annotated {@link Pageable} parameter are + * within their configured bounds. * *

Apply directly on a {@code Pageable} parameter. Each attribute is independently optional: *

    *
  • {@link #maxSize()} — when set (>= 0), validates {@code pageable.getPageSize() <= maxSize} *
  • {@link #maxPage()} — when set (>= 0), validates {@code pageable.getPageNumber() <= maxPage} + *
  • {@link #minSize()} — when set (>= 0), validates {@code pageable.getPageSize() >= minSize} + *
  • {@link #minPage()} — when set (>= 0), validates {@code pageable.getPageNumber() >= minPage} *
* *

Use {@link #NO_LIMIT} (= {@code -1}, the default) to leave an attribute unconstrained. @@ -43,6 +45,12 @@ public @interface ValidPageable { /** Maximum allowed page number (0-based), or {@link #NO_LIMIT} if unconstrained. */ int maxPage() default NO_LIMIT; + /** Minimum allowed page size, or {@link #NO_LIMIT} if unconstrained. */ + int minSize() default NO_LIMIT; + + /** Minimum allowed page number (0-based), or {@link #NO_LIMIT} if unconstrained. */ + int minPage() default NO_LIMIT; + Class[] groups() default {}; Class[] payload() default {}; @@ -53,11 +61,15 @@ public @interface ValidPageable { private int maxSize = NO_LIMIT; private int maxPage = NO_LIMIT; + private int minSize = NO_LIMIT; + private int minPage = NO_LIMIT; @Override public void initialize(ValidPageable constraintAnnotation) { maxSize = constraintAnnotation.maxSize(); maxPage = constraintAnnotation.maxPage(); + minSize = constraintAnnotation.minSize(); + minPage = constraintAnnotation.minPage(); } @Override @@ -93,6 +105,26 @@ public @interface ValidPageable { valid = false; } + if (minSize >= 0 && pageable.getPageSize() < minSize) { + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate() + + ": page size " + pageable.getPageSize() + + " is below minimum " + minSize) + .addPropertyNode("size") + .addConstraintViolation(); + valid = false; + } + + if (minPage >= 0 && pageable.getPageNumber() < minPage) { + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate() + + ": page number " + pageable.getPageNumber() + + " is below minimum " + minPage) + .addPropertyNode("page") + .addConstraintViolation(); + valid = false; + } + return valid; } } diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache index 1c7b263517fe..3eeb19e70bcd 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache @@ -48,7 +48,7 @@ * {{{description}}} * Values: {{#allowableValues}}{{#enumVars}}{{&name}}{{^-last}},{{/-last}}{{/enumVars}}{{/allowableValues}} */ - enum class {{{nameInPascalCase}}}(@get:JsonValue val value: {{#isContainer}}{{#items}}{{{dataType}}}{{/items}}{{/isContainer}}{{^isContainer}}{{{dataType}}}{{/isContainer}}) { + enum class {{{nameInPascalCase}}}(@get:JsonValue {{#useEnumValueInterface}}{{^isContainer}}override {{/isContainer}}{{/useEnumValueInterface}}val value: {{#isContainer}}{{#items}}{{{dataType}}}{{/items}}{{/isContainer}}{{^isContainer}}{{{dataType}}}{{/isContainer}}) {{#vendorExtensions.x-kotlin-implements}}{{#-first}}: {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}} {{/vendorExtensions.x-kotlin-implements}}{ {{#allowableValues}}{{#enumVars}} {{{name}}}({{{value}}}){{^-last}},{{/-last}}{{/enumVars}}{{/allowableValues}}; diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/enumClass.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/enumClass.mustache index 30916567a540..d22051e2367a 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/enumClass.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/enumClass.mustache @@ -2,7 +2,7 @@ * {{{description}}} * Values: {{#allowableValues}}{{#enumVars}}{{&name}}{{^-last}},{{/-last}}{{/enumVars}}{{/allowableValues}} */ -enum class {{classname}}(@get:JsonValue val value: {{dataType}}) {{#vendorExtensions.x-kotlin-implements}}{{#-first}}: {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}} {{/vendorExtensions.x-kotlin-implements}}{ +enum class {{classname}}(@get:JsonValue {{#useEnumValueInterface}}override {{/useEnumValueInterface}}val value: {{dataType}}) {{#vendorExtensions.x-kotlin-implements}}{{#-first}}: {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}} {{/vendorExtensions.x-kotlin-implements}}{ {{#allowableValues}}{{#enumVars}} {{&name}}({{{value}}}){{^-last}},{{/-last}}{{/enumVars}}{{/allowableValues}}; diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/enumValueInterface.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/enumValueInterface.mustache new file mode 100644 index 000000000000..a59700b58bbe --- /dev/null +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/enumValueInterface.mustache @@ -0,0 +1,6 @@ +package {{configPackage}} + +{{>generatedAnnotation}} +interface ValuedEnum { + val value: T +} diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/genericClass.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/genericClass.mustache new file mode 100644 index 000000000000..0415624c0cc3 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/genericClass.mustache @@ -0,0 +1,15 @@ +package {{configPackage}} +{{#genericClassDef}} +/** + * Generic class generated by openapi-generator from schema pattern '{{className}}'. + * Type parameters correspond to the varying domain types. + * + * To use your own class instead, supply a fully-qualified class name via + * `importMappings.{{className}}` in the generator config. + */ +data class {{className}}<{{#typeParams}}{{typeParam}}{{^isLast}}, {{/isLast}}{{/typeParams}}>( +{{#properties}} + val {{name}}: {{kotlinType}}{{^required}}? = null{{/required}}, +{{/properties}} +) +{{/genericClassDef}} diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/validPageable.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/validPageable.mustache index 6b26b7a26803..c87a9da537cb 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/validPageable.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/validPageable.mustache @@ -7,12 +7,14 @@ import {{javaxPackage}}.validation.Payload import org.springframework.data.domain.Pageable /** - * Validates that the page number and page size in the annotated [Pageable] parameter do not - * exceed their configured maximums. + * Validates that the page number and page size in the annotated [Pageable] parameter are within + * their configured bounds. * * Apply directly on a `pageable: Pageable` parameter. Each attribute is independently optional: * - [maxSize] — when set (>= 0), validates `pageable.pageSize <= maxSize` * - [maxPage] — when set (>= 0), validates `pageable.pageNumber <= maxPage` + * - [minSize] — when set (>= 0), validates `pageable.pageSize >= minSize` + * - [minPage] — when set (>= 0), validates `pageable.pageNumber >= minPage` * * Use [NO_LIMIT] (= -1, the default) to leave an attribute unconstrained. * @@ -21,6 +23,8 @@ import org.springframework.data.domain.Pageable * * @property maxSize Maximum allowed page size, or [NO_LIMIT] if unconstrained * @property maxPage Maximum allowed page number (0-based), or [NO_LIMIT] if unconstrained + * @property minSize Minimum allowed page size, or [NO_LIMIT] if unconstrained + * @property minPage Minimum allowed page number (0-based), or [NO_LIMIT] if unconstrained * @property groups Validation groups (optional) * @property payload Additional payload (optional) * @property message Validation error message (default: "Invalid page request") @@ -32,6 +36,8 @@ import org.springframework.data.domain.Pageable annotation class ValidPageable( val maxSize: Int = ValidPageable.NO_LIMIT, val maxPage: Int = ValidPageable.NO_LIMIT, + val minSize: Int = ValidPageable.NO_LIMIT, + val minPage: Int = ValidPageable.NO_LIMIT, val groups: Array> = [], val payload: Array> = [], val message: String = "Invalid page request" @@ -45,10 +51,14 @@ class PageableConstraintValidator : ConstraintValidator private var maxSize = ValidPageable.NO_LIMIT private var maxPage = ValidPageable.NO_LIMIT + private var minSize = ValidPageable.NO_LIMIT + private var minPage = ValidPageable.NO_LIMIT override fun initialize(constraintAnnotation: ValidPageable) { maxSize = constraintAnnotation.maxSize maxPage = constraintAnnotation.maxPage + minSize = constraintAnnotation.minSize + minPage = constraintAnnotation.minPage } override fun isValid(pageable: Pageable?, context: ConstraintValidatorContext): Boolean { @@ -76,6 +86,24 @@ class PageableConstraintValidator : ConstraintValidator valid = false } + if (minSize >= 0 && pageable.pageSize < minSize) { + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate}: page size ${pageable.pageSize} is below minimum $minSize" + ) + .addPropertyNode("size") + .addConstraintViolation() + valid = false + } + + if (minPage >= 0 && pageable.pageNumber < minPage) { + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate}: page number ${pageable.pageNumber} is below minimum $minPage" + ) + .addPropertyNode("page") + .addConstraintViolation() + valid = false + } + return valid } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index 920b2f6129f8..e795b987086e 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java @@ -50,7 +50,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.Consumer; @@ -67,9 +69,7 @@ import static org.openapitools.codegen.languages.SpringCodegen.*; import static org.openapitools.codegen.languages.features.DocumentationProviderFeatures.ANNOTATION_LIBRARY; import static org.openapitools.codegen.languages.features.DocumentationProviderFeatures.DOCUMENTATION_PROVIDER; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertNotNull; -import static org.testng.Assert.fail; +import static org.testng.Assert.*; public class SpringCodegenTest { @@ -7087,6 +7087,34 @@ public void autoXSpringPaginatedOnlyForSpringBoot() throws IOException { } } + @Test + public void explicitXSpringPaginatedIgnoredForSpringCloud() throws IOException { + // When x-spring-paginated: true is set explicitly in the spec but the library is spring-cloud, + // the extension must be stripped so the template does not emit "@ParameterObject Pageable pageable". + // Instead, individual page/size/sort @RequestParam args from the spec should remain. + Map props = new HashMap<>(); + props.put(SpringCodegen.INTERFACE_ONLY, "true"); + props.put(SpringCodegen.DOCUMENTATION_PROVIDER, "springdoc"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-with-spring-pageable.yaml", "spring-cloud", props); + + JavaFileAssert petApi = JavaFileAssert.assertThat(files.get("PetApi.java")); + + // No Pageable type, @ParameterObject annotation, or their imports must appear for spring-cloud + petApi.fileDoesNotContain("Pageable pageable", "@ParameterObject") + .hasNoImports( + "org.springframework.data.domain.Pageable", + "org.springdoc.core.annotations.ParameterObject"); + + // findPetsByStatus has only the 'status' param from the spec (no Pageable added) + petApi.assertMethod("findPetsByStatus", "List"); + + // findPetsByTags retains all individual query params defined alongside x-spring-paginated + // (page, size, sort remain; header 'size' also stays) + petApi.assertMethod("findPetsByTags", "List", "Integer", "Integer", "String", "String"); + } + @Test public void autoXSpringPaginatedDisabledByDefault() throws IOException { Map props = new HashMap<>(); @@ -7377,6 +7405,46 @@ public void generatePageableConstraintValidationWithBothConstraints() throws IOE .containsWithNameAndAttributes("ValidPageable", Map.of("maxSize", "50", "maxPage", "999")); } + @Test + public void generatePageableConstraintValidationResolvesMaximumFromAllOfRef() throws IOException { + Map props = new HashMap<>(); + props.put(SpringCodegen.INTERFACE_ONLY, "true"); + props.put(SpringCodegen.SKIP_DEFAULT_INTERFACE, "true"); + props.put(SpringCodegen.USE_TAGS, "true"); + props.put(SpringCodegen.USE_SPRING_BOOT3, "true"); + props.put(SpringCodegen.GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-sort-validation.yaml", SPRING_BOOT, props); + + // findPetsWithSizeConstraintFromAllOfRef: maximum: 75 is on the referenced schema only + JavaFileAssert.assertThat(files.get("PetApi.java")) + .assertMethod("findPetsWithSizeConstraintFromAllOfRef") + .assertParameter("pageable") + .assertParameterAnnotations() + .containsWithNameAndAttributes("ValidPageable", Map.of("maxSize", "75")); + } + + @Test + public void generatePageableConstraintValidationResolvesMinimumFromAllOfRef() throws IOException { + Map props = new HashMap<>(); + props.put(SpringCodegen.INTERFACE_ONLY, "true"); + props.put(SpringCodegen.SKIP_DEFAULT_INTERFACE, "true"); + props.put(SpringCodegen.USE_TAGS, "true"); + props.put(SpringCodegen.USE_SPRING_BOOT3, "true"); + props.put(SpringCodegen.GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-sort-validation.yaml", SPRING_BOOT, props); + + // findPetsWithMinSizeConstraintFromAllOfRef: minimum: 5 is on the referenced schema only + JavaFileAssert.assertThat(files.get("PetApi.java")) + .assertMethod("findPetsWithMinSizeConstraintFromAllOfRef") + .assertParameter("pageable") + .assertParameterAnnotations() + .containsWithNameAndAttributes("ValidPageable", Map.of("minSize", "5")); + } + // ------------------------------------------------------------------------- // @PageableDefault / @SortDefault tests // ------------------------------------------------------------------------- @@ -7728,6 +7796,424 @@ private Map springHttpInterfacePagedModelProps() { return props; } + // ========================================================================= + // genericPatterns integration tests + // ========================================================================= + + /** + * Builds common test props for genericPatterns feature tests. + * Uses annotationLibrary=none so that suppression is active. + */ + private Map genericPatternsProps() { + Map props = new HashMap<>(); + props.put(SpringCodegen.INTERFACE_ONLY, "true"); + props.put(SpringCodegen.SKIP_DEFAULT_INTERFACE, "true"); + props.put(SpringCodegen.USE_TAGS, "true"); + props.put(SpringCodegen.USE_SPRING_BOOT3, "true"); + props.put(DOCUMENTATION_PROVIDER, "none"); + props.put(ANNOTATION_LIBRARY, "none"); + + // Pattern 1: suffix=Response, slot=data, Mode B (simple class name → generate) + Map responsePattern = new HashMap<>(); + responsePattern.put("suffix", "Response"); + responsePattern.put("genericClass", "ApiResponse"); + responsePattern.put("slot", "data"); + + // Pattern 2: suffix=Page, slotArray=content, Mode A (FQN → import only) + Map pagePattern = new HashMap<>(); + pagePattern.put("suffix", "Page"); + pagePattern.put("genericClass", "org.springframework.data.domain.Page"); + pagePattern.put("slotArray", "content"); + + // Pattern 3: suffix=ErrorResult, slots: data→T + error→E, Mode B (multi-param) + Map resultPattern = new HashMap<>(); + resultPattern.put("suffix", "ErrorResult"); + resultPattern.put("genericClass", "Result"); + Map resultSlots = new LinkedHashMap<>(); + resultSlots.put("data", "T"); + resultSlots.put("error", "E"); + resultPattern.put("slots", resultSlots); + + props.put(SpringCodegen.GENERIC_PATTERNS, Arrays.asList(responsePattern, pagePattern, resultPattern)); + return props; + } + + @Test + public void genericPatterns_replacesReturnTypeForSuffixSlotPattern() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + // getUserResponse returns UserResponse → must become ApiResponse + JavaFileAssert.assertThat(files.get("ResponseApi.java")) + .assertMethod("getUserResponse") + .hasReturnType("ResponseEntity>"); + } + + @Test + public void genericPatterns_replacesReturnTypeForAllMatchedSchemas() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + JavaFileAssert.assertThat(files.get("ResponseApi.java")) + .assertMethod("getPetResponse").hasReturnType("ResponseEntity>") + .toFileAssert() + .assertMethod("getOrderResponse").hasReturnType("ResponseEntity>"); + } + + @Test + public void genericPatterns_suppressesConcreteSchemaClassesWhenNoAnnotations() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + // Concrete wrapper schemas should be suppressed + assertThat(files).doesNotContainKey("UserResponse.java"); + assertThat(files).doesNotContainKey("PetResponse.java"); + assertThat(files).doesNotContainKey("OrderResponse.java"); + } + + @Test + public void genericPatterns_keepsNonMatchedSchemas() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + // SearchResult is not matched → must still be generated + assertThat(files).containsKey("SearchResult.java"); + // Domain types must still be generated + assertThat(files).containsKey("User.java"); + assertThat(files).containsKey("Pet.java"); + } + + @Test + public void genericPatterns_modeBGeneratesClassFile() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + // Mode B: "ApiResponse" simple name → registered as SupportingFile, must appear in generate() output + assertThat(files).containsKey("ApiResponse.java"); + } + + @Test + public void genericPatterns_modeADoesNotGenerateClassFile() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + // Mode A: "org.springframework.data.domain.Page" FQN → no generated file + assertThat(files).doesNotContainKey("Page.java"); + } + + @Test + public void genericPatterns_slotArrayPatternReplacesReturnType() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + // listUsers returns UserPage → must become Page + JavaFileAssert.assertThat(files.get("PageApi.java")) + .assertMethod("listUsers") + .hasReturnType("ResponseEntity>"); + } + + @Test + public void genericPatterns_slotArrayAllOfPatternReplacesReturnType() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + // listPets returns PetPage (allOf form) → must become Page + JavaFileAssert.assertThat(files.get("PageApi.java")) + .assertMethod("listPets") + .hasReturnType("ResponseEntity>"); + } + + @Test + public void genericPatterns_tier1VendorExtensionDetected() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + // UserVendorResult has x-generic-class=com.example.generic.VendorResult → Mode A + // Operation getVendorUserResult returns VendorResult + JavaFileAssert.assertThat(files.get("VendorApi.java")) + .assertMethod("getVendorUserResult") + .hasReturnType("ResponseEntity>"); + } + + @Test + public void genericPatterns_disabledByDefault_concreteSchemaGenerated() throws IOException { + // Without genericPatterns, response schemas must still be generated as concrete classes + Map props = new HashMap<>(); + props.put(SpringCodegen.INTERFACE_ONLY, "true"); + props.put(SpringCodegen.SKIP_DEFAULT_INTERFACE, "true"); + props.put(SpringCodegen.USE_TAGS, "true"); + props.put(SpringCodegen.USE_SPRING_BOOT3, "true"); + props.put(DOCUMENTATION_PROVIDER, "none"); + props.put(ANNOTATION_LIBRARY, "none"); + // NOT setting GENERIC_PATTERNS + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, props); + + assertThat(files).containsKey("UserResponse.java"); + assertThat(files).containsKey("PetResponse.java"); + } + + // ========================================================================= + // Multi-type-parameter integration tests + // ========================================================================= + + @Test + public void genericPatterns_multiParam_replacesReturnTypeWithTwoTypeArgs() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + // getUserErrorResult returns UserErrorResult → must become Result + JavaFileAssert.assertThat(files.get("ResultApi.java")) + .assertMethod("getUserErrorResult") + .hasReturnType("ResponseEntity>"); + } + + @Test + public void genericPatterns_multiParam_replacesReturnTypeForAllMatchedSchemas() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + JavaFileAssert.assertThat(files.get("ResultApi.java")) + .assertMethod("getOrderErrorResult") + .hasReturnType("ResponseEntity>"); + } + + @Test + public void genericPatterns_multiParam_suppressesConcreteSchemas() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + assertThat(files).doesNotContainKey("UserErrorResult.java"); + assertThat(files).doesNotContainKey("OrderErrorResult.java"); + } + + @Test + public void genericPatterns_multiParam_modeBGeneratesResultClassFile() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + // Mode B: "Result" simple name → registered as SupportingFile + assertThat(files).containsKey("Result.java"); + } + + @Test + public void genericPatterns_multiParam_resultClassHasTwoTypeParams() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + // Result.java must declare class Result + JavaFileAssert.assertThat(files.get("Result.java")) + .fileContains("public class Result"); + } + + @Test + public void genericPatterns_singleParam_resultClassStillHasOneTypeParam() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + // ApiResponse.java must still declare class ApiResponse (not ) + JavaFileAssert.assertThat(files.get("ApiResponse.java")) + .fileContains("public class ApiResponse"); + } + + // ------------------------------------------------------------------------- + // genericPatterns — modelNameSuffix / modelNamePrefix + // ------------------------------------------------------------------------- + + @Test + public void genericPatterns_withModelNameSuffix_replacesReturnType() throws IOException { + // When modelNameSuffix is set, op.returnBaseType includes the suffix (e.g. "UserResponseDto"). + // The registry must be re-keyed with toModelName() so the lookup succeeds. + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps(), + configurator -> configurator.addAdditionalProperty("modelNameSuffix", "Dto")); + + // getUserResponse returns UserResponse → suffix applied → UserResponseDto → replaced with ApiResponse + JavaFileAssert.assertThat(files.get("ResponseApi.java")) + .assertMethod("getUserResponse") + .hasReturnType("ResponseEntity>"); + } + + @Test + public void genericPatterns_withModelNamePrefix_replacesReturnType() throws IOException { + // When modelNamePrefix is set, op.returnBaseType includes the prefix (e.g. "MyUserResponse"). + // The registry must be re-keyed with toModelName() so the lookup succeeds. + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps(), + configurator -> configurator.addAdditionalProperty("modelNamePrefix", "My")); + + // getUserResponse returns UserResponse → prefix applied → MyUserResponse → replaced with ApiResponse + JavaFileAssert.assertThat(files.get("ResponseApi.java")) + .assertMethod("getUserResponse") + .hasReturnType("ResponseEntity>"); + } + + @Test + public void genericPatterns_withModelNameSuffix_suppressesConcreteSchemaClasses() throws IOException { + // Verify schema suppression works with modelNameSuffix: + // objs keys are raw schema names; inst.schemaName (raw) must be used for removal. + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps(), + configurator -> configurator.addAdditionalProperty("modelNameSuffix", "Dto")); + + // Concrete wrapper schemas are suppressed (raw name + suffix = e.g. UserResponseDto.java) + assertThat(files).doesNotContainKey("UserResponseDto.java"); + assertThat(files).doesNotContainKey("PetResponseDto.java"); + assertThat(files).doesNotContainKey("OrderResponseDto.java"); + } + + // ------------------------------------------------------------------------- + // genericPatterns — property-level substitution (Scenarios A, B, F, G) + // ------------------------------------------------------------------------- + + @Test + public void genericPatterns_substitutesPropertyTypeRef() throws IOException { + // Scenario A: OrderDetails.userResult: $ref UserResponse → ApiResponse + // The plain domain property (pet: Pet) must NOT be changed. + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + JavaFileAssert.assertThat(files.get("OrderDetails.java")) + .fileContains("ApiResponse") + .fileDoesNotContain("UserResponse userResult"); + JavaFileAssert.assertThat(files.get("OrderDetails.java")) + .fileContains("Pet pet"); + } + + @Test + public void genericPatterns_substitutesArrayPropertyTypeRef() throws IOException { + // Scenario B: NotificationBatch.responses: array of $ref UserResponse → List> + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + JavaFileAssert.assertThat(files.get("NotificationBatch.java")) + .fileContains("ApiResponse") + .fileDoesNotContain("UserResponse"); + } + + @Test + public void genericPatterns_suppressionDoesNotBreakModelWithSubstitutedProperty() throws IOException { + // Scenario G: end-to-end coherence check: + // OrderDetails.java has correct ApiResponse type AND UserResponse.java is suppressed. + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + assertThat(files).doesNotContainKey("UserResponse.java"); + assertThat(files).containsKey("OrderDetails.java"); + JavaFileAssert.assertThat(files.get("OrderDetails.java")) + .fileContains("ApiResponse"); + } + + @Test + public void genericPatterns_withModelNameSuffix_substitutesPropertyTypeRef() throws IOException { + // Scenario F: property substitution combined with modelNameSuffix=Dto + // OrderDetails.userResult → ApiResponse (not ApiResponse) + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps(), + configurator -> configurator.addAdditionalProperty("modelNameSuffix", "Dto")); + + JavaFileAssert.assertThat(files.get("OrderDetailsDto.java")) + .fileContains("ApiResponse") + .fileDoesNotContain("UserResponseDto"); + } + + // ------------------------------------------------------------------------- + // genericPatterns — recursive type-arg expansion (Scenarios C, D) + // ------------------------------------------------------------------------- + + @Test + public void genericPatterns_recursiveTypeArgInReturnType() throws IOException { + // Scenario C: UserResponsePage matched as Page where T=UserResponse (itself ApiResponse) + // Expected: listUserResponses → Page> + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + JavaFileAssert.assertThat(files.get("PageApi.java")) + .assertMethod("listUserResponses") + .hasReturnType("ResponseEntity>>"); + } + + @Test + public void genericPatterns_recursiveTypeArgInMultiParamReturn() throws IOException { + // Scenario D: UserResponseErrorResult matched as Result + // where T=UserResponse (itself ApiResponse) and E=ValidationError (plain type) + // Expected: getUserResponseErrorResult → Result, ValidationError> + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); + + JavaFileAssert.assertThat(files.get("ResultApi.java")) + .assertMethod("getUserResponseErrorResult") + .hasReturnType("ResponseEntity, ValidationError>>"); + } + + // ------------------------------------------------------------------------- + // genericPatterns — suppression safety check (Scenario E) + // ------------------------------------------------------------------------- + + @Test + public void genericPatterns_suppressionSafety_keepsSchemaWhenExtended() throws IOException { + // Scenario E: ExtendedUserResponse uses allOf composition (no discriminator → no Java + // inheritance, model.parent = null). UserResponse is a detected generic instance and CAN + // be safely suppressed — composition merges its properties into ExtendedUserResponse directly. + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics-inheritance.yaml", SPRING_BOOT, + genericPatternsProps()); + + // UserResponse IS correctly suppressed (composition, not inheritance → safe) + assertThat(files).doesNotContainKey("UserResponse.java"); + // ExtendedUserResponse is generated with its own merged properties + assertThat(files).containsKey("ExtendedUserResponse.java"); + } + + // ------------------------------------------------------------------------- + // genericPatterns — schemaMapping interaction (Gap B) + // ------------------------------------------------------------------------- + + @Test + public void genericPatterns_schemaMappingOnInstance_skipsSubstitution() throws IOException { + // Gap B: when a generic instance schema is also in schemaMapping, the user's schemaMapping + // intent takes precedence and no substitution occurs for that instance. + // Only UserResponse is schema-mapped; PetResponse and OrderResponse should still be substituted. + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps(), + configurator -> configurator.addSchemaMapping("UserResponse", "com.example.external.UserResponse")); + + // getUserResponse must NOT have been rewritten to ApiResponse + JavaFileAssert.assertThat(files.get("ResponseApi.java")) + .fileDoesNotContain("ApiResponse"); + + // Other instances (PetResponse, OrderResponse) must still be substituted + JavaFileAssert.assertThat(files.get("ResponseApi.java")) + .assertMethod("getPetResponse") + .hasReturnType("ResponseEntity>"); + } + // ------------------------------------------------------------------------- // substituteGenericPagedModel — spring-cloud // ------------------------------------------------------------------------- @@ -7784,6 +8270,59 @@ private Map springCloudPagedModelProps() { } + // ------------------------------------------------------------------------- + // substituteGenericPagedModel — modelNameSuffix / modelNamePrefix + // ------------------------------------------------------------------------- + + @Test + public void substituteGenericPagedModel_withModelNameSuffix_replacesReturnType() throws IOException { + // When modelNameSuffix is set the returnBaseType includes the suffix, + // so the registry lookup must also use the suffix-applied key. + Map props = commonPagedModelProps(); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", SPRING_BOOT, props, + configurator -> configurator.addAdditionalProperty("modelNameSuffix", "Dto")); + + // listUsers returns UserPage → suffix applied → UserPageDto → replaced with PagedModel + JavaFileAssert.assertThat(files.get("UserApi.java")) + .assertMethod("listUsers") + .hasReturnType("ResponseEntity>"); + } + + @Test + public void substituteGenericPagedModel_withModelNamePrefix_replacesReturnType() throws IOException { + // When modelNamePrefix is set the returnBaseType includes the prefix, + // so the registry lookup must also use the prefix-applied key. + Map props = commonPagedModelProps(); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", SPRING_BOOT, props, + configurator -> configurator.addAdditionalProperty("modelNamePrefix", "My")); + + // listUsers returns UserPage → prefix applied → MyUserPage → replaced with PagedModel + JavaFileAssert.assertThat(files.get("UserApi.java")) + .assertMethod("listUsers") + .hasReturnType("ResponseEntity>"); + } + + @Test + public void substituteGenericPagedModel_withModelNameSuffix_suppressesPagedSchemasWhenNoAnnotations() + throws IOException { + // Verify schema suppression also works correctly under modelNameSuffix + // (objs keys are suffix-applied, registry keys must match them). + Map props = noAnnotationPagedModelProps(); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", SPRING_BOOT, props, + configurator -> configurator.addAdditionalProperty("modelNameSuffix", "Dto")); + + assertThat(files).doesNotContainKey("UserPageDto.java"); + assertThat(files).doesNotContainKey("OrderPageDto.java"); + assertThat(files).doesNotContainKey("PetPageAllOfDto.java"); + } + + @DataProvider(name = "replaceOneOf") public Object[][] replaceOneOf() { return new Object[][]{ @@ -7927,6 +8466,77 @@ void disableDiscriminatorJsonIgnorePropertiesIsTrueThenJsonIgnorePropertiesShoul .assertTypeAnnotations().containsWithName("JsonIgnoreProperties"); } + // useEnumValueInterface tests + // ------------------------------------------------------------------------- + + @Test + public void useEnumValueInterface_isDisabledByDefault() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", SPRING_BOOT, new HashMap<>()); + + assertThat(files).doesNotContainKey("ValuedEnum.java"); + JavaFileAssert.assertThat(files.get("OrderStatus.java")) + .fileDoesNotContain("implements ValuedEnum"); + } + + @Test + public void useEnumValueInterface_generatesInterface() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", SPRING_BOOT, + Map.of(USE_ENUM_VALUE_INTERFACE, "true")); + + assertThat(files).containsKey("ValuedEnum.java"); + JavaFileAssert.assertThat(files.get("ValuedEnum.java")) + .isInterface() + .fileContains("interface ValuedEnum"); + } + + @Test + public void useEnumValueInterface_topLevelEnumImplementsInterface() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", SPRING_BOOT, + Map.of(USE_ENUM_VALUE_INTERFACE, "true")); + + JavaFileAssert.assertThat(files.get("OrderStatus.java")) + .fileContains("implements ValuedEnum") + .hasImports("org.openapitools.configuration.ValuedEnum"); + } + + @Test + public void useEnumValueInterface_inlineEnumImplementsInterface() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", SPRING_BOOT, + Map.of(USE_ENUM_VALUE_INTERFACE, "true")); + + JavaFileAssert.assertThat(files.get("Order.java")) + .fileContains("implements ValuedEnum") + .hasImports("org.openapitools.configuration.ValuedEnum"); + } + + @Test + public void useEnumValueInterface_noFileGeneratedWithCustomImportMapping() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", SPRING_BOOT, + Map.of(USE_ENUM_VALUE_INTERFACE, "true"), + configurator -> configurator + .addImportMapping("ValuedEnum", "com.example.custom.ValuedEnum")); + + assertThat(files).doesNotContainKey("ValuedEnum.java"); + } + + @Test + public void useEnumValueInterface_customImportMappingUsedInGeneratedCode() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", SPRING_BOOT, + Map.of(USE_ENUM_VALUE_INTERFACE, "true"), + configurator -> configurator + .addImportMapping("ValuedEnum", "com.example.custom.ValuedEnum")); + + JavaFileAssert.assertThat(files.get("OrderStatus.java")) + .fileContains("implements ValuedEnum") + .hasImports("com.example.custom.ValuedEnum"); + } + @Test void schemaMappingWithNullableAllOfRendersNullableJavaProperty() throws IOException { // When a schema is substituted via schemaMapping and a property wraps it with @@ -7941,4 +8551,4 @@ void schemaMappingWithNullableAllOfRendersNullableJavaProperty() throws IOExcept JavaFileAssert.assertThat(files.get("MyObject.java")) .assertProperty("optionalRef").withType("JsonNullable"); } -} +} \ No newline at end of file diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index 0e83e957927d..2da9dc415623 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -45,6 +45,7 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; +import static org.openapitools.codegen.CodegenConstants.USE_ENUM_VALUE_INTERFACE; import static org.openapitools.codegen.TestUtils.assertFileContains; import static org.openapitools.codegen.TestUtils.assertFileNotContains; import static org.openapitools.codegen.languages.KotlinSpringServerCodegen.*; @@ -152,9 +153,9 @@ public void testNoRequestMappingAnnotationController() throws IOException { Paths.get(output + "/src/main/kotlin/org/openapitools/api/PetApiController.kt"), "@RequestMapping(\"\\${api.base-path:/v2}\")", " companion object {\n" - + " //for your own safety never directly reuse these path definitions in tests\n" - + " const val BASE_PATH: String = \"/v2\"\n" - + " }" + + " //for your own safety never directly reuse these path definitions in tests\n" + + " const val BASE_PATH: String = \"/v2\"\n" + + " }" ); } @@ -169,8 +170,8 @@ public void testNoRequestMappingAnnotationApiInterface() throws IOException { Paths.get(output + "/src/main/kotlin/org/openapitools/api/PetApi.kt"), "@RequestMapping(\"\\${api.base-path:/v2}\")", " companion object {\n" - + " //for your own safety never directly reuse these path definitions in tests\n" - + " const val BASE_PATH: String = \"/v2\"" + + " //for your own safety never directly reuse these path definitions in tests\n" + + " const val BASE_PATH: String = \"/v2\"" ); // Check that the @RequestMapping annotation is not generated in the ApiController file assertFileNotContains( @@ -419,16 +420,16 @@ public void testNullableMultipartFile() throws IOException { assertFileContains(Paths.get(outputPath + "/src/main/kotlin/org/openapitools/api/NullableMultipartfileApiController.kt"), "file: org.springframework.web.multipart.MultipartFile?" - + " )"); + + " )"); assertFileContains(Paths.get(outputPath + "/src/main/kotlin/org/openapitools/api/NullableMultipartfileArrayApiController.kt"), "files: Array?" - + " )"); + + " )"); assertFileContains(Paths.get(outputPath + "/src/main/kotlin/org/openapitools/api/NonNullableMultipartfileApiController.kt"), "file: org.springframework.web.multipart.MultipartFile" - + " )"); + + " )"); assertFileContains(Paths.get(outputPath + "/src/main/kotlin/org/openapitools/api/NonNullableMultipartfileArrayApiController.kt"), "files: Array" - + " )"); + + " )"); } @Test @@ -760,12 +761,12 @@ private static void testMultiLineOperationDescription(final boolean isInterfaceO Paths.get( outputPath + "/src/main/kotlin/org/openapitools/api/" + pingApiFileName), "description = \"\"\"# Multi-line descriptions\n" - + "\n" - + "This is an example of a multi-line description.\n" - + "\n" - + "It:\n" - + "- has multiple lines\n" - + "- uses Markdown (CommonMark) for rich text representation\"\"\"" + + "\n" + + "This is an example of a multi-line description.\n" + + "\n" + + "It:\n" + + "- has multiple lines\n" + + "- uses Markdown (CommonMark) for rich text representation\"\"\"" ); } @@ -1279,38 +1280,38 @@ public void generateHttpInterfaceReactiveWithReactorResponseEntity() throws Exce assertFileContains( path, "import reactor.core.publisher.Flux\n" - + "import reactor.core.publisher.Mono", + + "import reactor.core.publisher.Mono", " @HttpExchange(\n" - + " // \"/store/inventory\"\n" - + " url = PATH_GET_INVENTORY,\n" - + " method = \"GET\"\n" - + " )\n" - + " fun getInventory(\n" - + " ): Mono>>", + + " // \"/store/inventory\"\n" + + " url = PATH_GET_INVENTORY,\n" + + " method = \"GET\"\n" + + " )\n" + + " fun getInventory(\n" + + " ): Mono>>", " @HttpExchange(\n" - + " // \"/store/order/{orderId}\"\n" - + " url = PATH_DELETE_ORDER,\n" - + " method = \"DELETE\"\n" - + " )\n" - + " fun deleteOrder(\n" - + " @Parameter(description = \"ID of the order that needs to be deleted\", required = true) @PathVariable(\"orderId\") orderId: kotlin.String\n" - + " ): Mono>", + + " // \"/store/order/{orderId}\"\n" + + " url = PATH_DELETE_ORDER,\n" + + " method = \"DELETE\"\n" + + " )\n" + + " fun deleteOrder(\n" + + " @Parameter(description = \"ID of the order that needs to be deleted\", required = true) @PathVariable(\"orderId\") orderId: kotlin.String\n" + + " ): Mono>", " @HttpExchange(\n" - + " // \"/store/order\"\n" - + " url = PATH_PLACE_ORDER,\n" - + " method = \"POST\"\n" - + " )\n" - + " fun placeOrder(\n" - + " @Parameter(description = \"order placed for purchasing the pet\", required = true) @Valid @RequestBody order: Order\n" - + " ): Mono>", + + " // \"/store/order\"\n" + + " url = PATH_PLACE_ORDER,\n" + + " method = \"POST\"\n" + + " )\n" + + " fun placeOrder(\n" + + " @Parameter(description = \"order placed for purchasing the pet\", required = true) @Valid @RequestBody order: Order\n" + + " ): Mono>", " companion object {\n" - + " //for your own safety never directly reuse these path definitions in tests\n" - + " const val BASE_PATH: String = \"/v2\"\n" - + " const val PATH_DELETE_ORDER: String = \"/store/order/{orderId}\"\n" - + " const val PATH_GET_INVENTORY: String = \"/store/inventory\"\n" - + " const val PATH_GET_ORDER_BY_ID: String = \"/store/order/{orderId}\"\n" - + " const val PATH_PLACE_ORDER: String = \"/store/order\"\n" - + " }" + + " //for your own safety never directly reuse these path definitions in tests\n" + + " const val BASE_PATH: String = \"/v2\"\n" + + " const val PATH_DELETE_ORDER: String = \"/store/order/{orderId}\"\n" + + " const val PATH_GET_INVENTORY: String = \"/store/inventory\"\n" + + " const val PATH_GET_ORDER_BY_ID: String = \"/store/order/{orderId}\"\n" + + " const val PATH_PLACE_ORDER: String = \"/store/order\"\n" + + " }" ); assertFileNotContains( path, @@ -1351,13 +1352,13 @@ public void generateHttpInterfaceReactiveWithCoroutinesResponseEntity() throws E assertFileContains( path, " suspend fun getInventory(\n" - + " ): ResponseEntity>", + + " ): ResponseEntity>", " suspend fun deleteOrder(\n" - + " @Parameter(description = \"ID of the order that needs to be deleted\", required = true) @PathVariable(\"orderId\") orderId: kotlin.String\n" - + " ): ResponseEntity", + + " @Parameter(description = \"ID of the order that needs to be deleted\", required = true) @PathVariable(\"orderId\") orderId: kotlin.String\n" + + " ): ResponseEntity", " suspend fun placeOrder(\n" - + " @Parameter(description = \"order placed for purchasing the pet\", required = true) @Valid @RequestBody order: Order\n" - + " ): ResponseEntity" + + " @Parameter(description = \"order placed for purchasing the pet\", required = true) @Valid @RequestBody order: Order\n" + + " ): ResponseEntity" ); } @@ -1393,15 +1394,15 @@ public void generateHttpInterfaceReactiveWithReactor() throws Exception { assertFileContains( path, "import reactor.core.publisher.Flux\n" - + "import reactor.core.publisher.Mono", + + "import reactor.core.publisher.Mono", " fun getInventory(\n" - + " ): Mono>", + + " ): Mono>", " fun deleteOrder(\n" - + " @Parameter(description = \"ID of the order that needs to be deleted\", required = true) @PathVariable(\"orderId\") orderId: kotlin.String\n" - + " ): Mono", + + " @Parameter(description = \"ID of the order that needs to be deleted\", required = true) @PathVariable(\"orderId\") orderId: kotlin.String\n" + + " ): Mono", " fun placeOrder(\n" - + " @Parameter(description = \"order placed for purchasing the pet\", required = true) @Valid @RequestBody order: Order\n" - + " ): Mono" + + " @Parameter(description = \"order placed for purchasing the pet\", required = true) @Valid @RequestBody order: Order\n" + + " ): Mono" ); assertFileNotContains( path, @@ -1441,13 +1442,13 @@ public void generateHttpInterfaceReactiveWithCoroutines() throws Exception { assertFileContains( path, " suspend fun getInventory(\n" - + " ): Map", + + " ): Map", " suspend fun deleteOrder(\n" - + " @Parameter(description = \"ID of the order that needs to be deleted\", required = true) @PathVariable(\"orderId\") orderId: kotlin.String\n" - + " ): Unit", + + " @Parameter(description = \"ID of the order that needs to be deleted\", required = true) @PathVariable(\"orderId\") orderId: kotlin.String\n" + + " ): Unit", " suspend fun placeOrder(\n" - + " @Parameter(description = \"order placed for purchasing the pet\", required = true) @Valid @RequestBody order: Order\n" - + " ): Order" + + " @Parameter(description = \"order placed for purchasing the pet\", required = true) @Valid @RequestBody order: Order\n" + + " ): Order" ); } @@ -1482,13 +1483,13 @@ public void generateHttpInterfaceResponseEntity() throws Exception { assertFileContains( path, " fun getInventory(\n" - + " ): ResponseEntity>", + + " ): ResponseEntity>", " fun deleteOrder(\n" - + " @Parameter(description = \"ID of the order that needs to be deleted\", required = true) @PathVariable(\"orderId\") orderId: kotlin.String\n" - + " ): ResponseEntity", + + " @Parameter(description = \"ID of the order that needs to be deleted\", required = true) @PathVariable(\"orderId\") orderId: kotlin.String\n" + + " ): ResponseEntity", " fun placeOrder(\n" - + " @Parameter(description = \"order placed for purchasing the pet\", required = true) @Valid @RequestBody order: Order\n" - + " ): ResponseEntity" + + " @Parameter(description = \"order placed for purchasing the pet\", required = true) @Valid @RequestBody order: Order\n" + + " ): ResponseEntity" ); assertFileNotContains( path, @@ -1530,13 +1531,13 @@ public void generateHttpInterface() throws Exception { "import org.openapitools.api.StoreApi.Companion.BASE_PATH", "@HttpExchange(BASE_PATH) // Generate with 'requestMappingMode' set to 'none' to skip the base path on the interface", // this should be present since "requestMappingMode" is set to "api_interface" " fun getInventory(\n" - + " ): Map", + + " ): Map", " fun deleteOrder(\n" - + " @Parameter(description = \"ID of the order that needs to be deleted\", required = true) @PathVariable(\"orderId\") orderId: kotlin.String\n" - + " ): Unit", + + " @Parameter(description = \"ID of the order that needs to be deleted\", required = true) @PathVariable(\"orderId\") orderId: kotlin.String\n" + + " ): Unit", " fun placeOrder(\n" - + " @Parameter(description = \"order placed for purchasing the pet\", required = true) @Valid @RequestBody order: Order\n" - + " ): Order" + + " @Parameter(description = \"order placed for purchasing the pet\", required = true) @Valid @RequestBody order: Order\n" + + " ): Order" ); assertFileNotContains( path, @@ -1639,14 +1640,14 @@ public void reactiveWithHttpRequestContextControllerImplAnnotationSwaggerNoDeleg Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApiController.kt"), List.of( "deletePet(" - + " @Parameter(description = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @Parameter(description = \"\", `in` = ParameterIn.HEADER) @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " @Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity", + + " @Parameter(description = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @Parameter(description = \"\", `in` = ParameterIn.HEADER) @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " @Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity", "getPetById(" - + " @Parameter(description = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity"), + + " @Parameter(description = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApiController.kt"), List.of( "logoutUser(@Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange)") ) @@ -1673,14 +1674,14 @@ public void reactiveWithHttpRequestContextControllerImplAnnotationSwagger1NoDele Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApiController.kt"), List.of( "deletePet(" - + " @ApiParam(value = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @ApiParam(value = \"\") @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " @ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity", + + " @ApiParam(value = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @ApiParam(value = \"\") @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " @ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity", "getPetById(" - + " @ApiParam(value = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity"), + + " @ApiParam(value = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApiController.kt"), List.of( "logoutUser(@ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange") ) @@ -1707,14 +1708,14 @@ public void reactiveWithHttpRequestContextControllerImplAnnotationNoneNoDelegate Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApiController.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApiController.kt"), List.of( "logoutUser(exchange: org.springframework.web.server.ServerWebExchange): ResponseEntity") ) @@ -1741,12 +1742,12 @@ public void reactiveWithoutHttpRequestContextControllerImplAnnotationNoneNoDeleg Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApiController.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApiController.kt"), List.of( "logoutUser(): ResponseEntity") ) @@ -1773,14 +1774,14 @@ public void nonReactiveWithHttpRequestContextControllerImplAnnotationSwaggerNoDe Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApiController.kt"), List.of( "deletePet(" - + " @Parameter(description = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @Parameter(description = \"\", `in` = ParameterIn.HEADER) @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " @Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity", + + " @Parameter(description = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @Parameter(description = \"\", `in` = ParameterIn.HEADER) @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " @Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity", "getPetById(" - + " @Parameter(description = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity"), + + " @Parameter(description = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApiController.kt"), List.of( "logoutUser(@Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest): ResponseEntity") ) @@ -1807,14 +1808,14 @@ public void nonReactiveWithHttpRequestContextControllerImplAnnotationSwagger1NoD Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApiController.kt"), List.of( "deletePet(" - + " @ApiParam(value = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @ApiParam(value = \"\") @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " @ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity", + + " @ApiParam(value = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @ApiParam(value = \"\") @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " @ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity", "getPetById(" - + " @ApiParam(value = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity"), + + " @ApiParam(value = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApiController.kt"), List.of( "logoutUser(@ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest): ResponseEntity") ) @@ -1841,14 +1842,14 @@ public void nonReactiveWithHttpRequestContextControllerImplAnnotationNoneNoDeleg Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApiController.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApiController.kt"), List.of( "logoutUser(request: javax.servlet.http.HttpServletRequest): ResponseEntity") ) @@ -1875,12 +1876,12 @@ public void nonReactiveWithoutHttpRequestContextControllerImplAnnotationNoneNoDe Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApiController.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApiController.kt"), List.of( "logoutUser(): ResponseEntity") ) @@ -1907,14 +1908,14 @@ public void reactiveWithHttpRequestContextInterfaceOnlyAnnotationSwaggerNoDelega Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @Parameter(description = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @Parameter(description = \"\", `in` = ParameterIn.HEADER) @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " @Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity", + + " @Parameter(description = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @Parameter(description = \"\", `in` = ParameterIn.HEADER) @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " @Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity", "getPetById(" - + " @Parameter(description = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity"), + + " @Parameter(description = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(@Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange): ResponseEntity") ) @@ -1941,14 +1942,14 @@ public void reactiveWithHttpRequestContextInterfaceOnlyAnnotationSwagger1NoDeleg Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @ApiParam(value = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @ApiParam(value = \"\") @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " @ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity", + + " @ApiParam(value = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @ApiParam(value = \"\") @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " @ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity", "getPetById(" - + " @ApiParam(value = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity"), + + " @ApiParam(value = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(@ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange): ResponseEntity") ) @@ -1975,14 +1976,14 @@ public void reactiveWithHttpRequestContextInterfaceOnlyAnnotationNoneNoDelegate( Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(exchange: org.springframework.web.server.ServerWebExchange): ResponseEntity") ) @@ -2009,12 +2010,12 @@ public void reactiveWithoutHttpRequestContextInterfaceOnlyAnnotationNoneNoDelega Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(): ResponseEntity") ) @@ -2041,14 +2042,14 @@ public void nonReactiveWithHttpRequestContextInterfaceOnlyAnnotationSwaggerNoDel Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @Parameter(description = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @Parameter(description = \"\", `in` = ParameterIn.HEADER) @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " @Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity", + + " @Parameter(description = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @Parameter(description = \"\", `in` = ParameterIn.HEADER) @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " @Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity", "getPetById(" - + " @Parameter(description = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity"), + + " @Parameter(description = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(@Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest): ResponseEntity") ) @@ -2075,14 +2076,14 @@ public void nonReactiveWithHttpRequestContextInterfaceOnlyAnnotationSwagger1NoDe Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @ApiParam(value = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @ApiParam(value = \"\") @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " @ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity", + + " @ApiParam(value = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @ApiParam(value = \"\") @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " @ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity", "getPetById(" - + " @ApiParam(value = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity"), + + " @ApiParam(value = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(@ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest): ResponseEntity") ) @@ -2109,14 +2110,14 @@ public void nonReactiveWithHttpRequestContextInterfaceOnlyAnnotationNoneNoDelega Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(request: javax.servlet.http.HttpServletRequest): ResponseEntity") ) @@ -2144,14 +2145,14 @@ public void nonReactiveWithHttpRequestContextControllerImplAnnotationNoneNoDeleg Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApiController.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApiController.kt"), List.of( "logoutUser(request: javax.servlet.http.HttpServletRequest): ResponseEntity"), root.resolve("src/test/kotlin/org/openapitools/api/PetApiTest.kt"), List.of( @@ -2185,14 +2186,14 @@ public void reactiveWithHttpRequestContextControllerImplAnnotationNoneNoDelegate Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApiController.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApiController.kt"), List.of( "logoutUser(exchange: org.springframework.web.server.ServerWebExchange)"), root.resolve("src/test/kotlin/org/openapitools/api/PetApiTest.kt"), List.of( @@ -2225,12 +2226,12 @@ public void nonReactiveWithoutHttpRequestContextInterfaceOnlyAnnotationNoneNoDel Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(): ResponseEntity") ) @@ -2257,14 +2258,14 @@ public void reactiveWithHttpRequestContextControllerImplAnnotationSwaggerDelegat Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @Parameter(description = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @Parameter(description = \"\", `in` = ParameterIn.HEADER) @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " @Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity", + + " @Parameter(description = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @Parameter(description = \"\", `in` = ParameterIn.HEADER) @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " @Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity", "getPetById(" - + " @Parameter(description = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity"), + + " @Parameter(description = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(@Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( @@ -2296,14 +2297,14 @@ public void reactiveWithHttpRequestContextControllerImplAnnotationSwagger1Delega Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @ApiParam(value = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @ApiParam(value = \"\") @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " @ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity", + + " @ApiParam(value = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @ApiParam(value = \"\") @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " @ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity", "getPetById(" - + " @ApiParam(value = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity"), + + " @ApiParam(value = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(@ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( @@ -2335,14 +2336,14 @@ public void reactiveWithHttpRequestContextControllerImplAnnotationNoneDelegate() Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(exchange: org.springframework.web.server.ServerWebExchange): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( @@ -2374,12 +2375,12 @@ public void reactiveWithoutHttpRequestContextControllerImplAnnotationNoneDelegat Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( @@ -2411,14 +2412,14 @@ public void nonReactiveWithHttpRequestContextControllerImplAnnotationSwaggerDele Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @Parameter(description = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @Parameter(description = \"\", `in` = ParameterIn.HEADER) @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " @Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity", + + " @Parameter(description = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @Parameter(description = \"\", `in` = ParameterIn.HEADER) @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " @Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity", "getPetById(" - + " @Parameter(description = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity"), + + " @Parameter(description = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(@Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( @@ -2450,14 +2451,14 @@ public void nonReactiveWithHttpRequestContextControllerImplAnnotationSwagger1Del Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @ApiParam(value = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @ApiParam(value = \"\") @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " @ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity", + + " @ApiParam(value = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @ApiParam(value = \"\") @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " @ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity", "getPetById(" - + " @ApiParam(value = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity"), + + " @ApiParam(value = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(@ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( @@ -2489,14 +2490,14 @@ public void nonReactiveWithHttpRequestContextControllerImplAnnotationNoneDelegat Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "fun deletePet(\n" - + " @PathVariable(\"petId\") petId: kotlin.Long,\n" - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?,\n" - + " request: javax.servlet.http.HttpServletRequest\n" - + " ): ResponseEntity {", + + " @PathVariable(\"petId\") petId: kotlin.Long,\n" + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?,\n" + + " request: javax.servlet.http.HttpServletRequest\n" + + " ): ResponseEntity {", "fun getPetById(\n" - + " @PathVariable(\"petId\") petId: kotlin.Long,\n" - + " request: javax.servlet.http.HttpServletRequest\n" - + " ): ResponseEntity {"), + + " @PathVariable(\"petId\") petId: kotlin.Long,\n" + + " request: javax.servlet.http.HttpServletRequest\n" + + " ): ResponseEntity {"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(request: javax.servlet.http.HttpServletRequest): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( @@ -2528,12 +2529,12 @@ public void nonReactiveWithoutHttpRequestContextControllerImplAnnotationNoneDele Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( @@ -2565,14 +2566,14 @@ public void reactiveWithHttpRequestContextInterfaceOnlyAnnotationSwaggerDelegate Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @Parameter(description = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @Parameter(description = \"\", `in` = ParameterIn.HEADER) @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " @Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity", + + " @Parameter(description = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @Parameter(description = \"\", `in` = ParameterIn.HEADER) @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " @Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity", "getPetById(" - + " @Parameter(description = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity"), + + " @Parameter(description = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(@Parameter(hidden = true) exchange: org.springframework.web.server.ServerWebExchange): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( @@ -2604,14 +2605,14 @@ public void reactiveWithHttpRequestContextInterfaceOnlyAnnotationSwagger1Delegat Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @ApiParam(value = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @ApiParam(value = \"\") @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " @ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity", + + " @ApiParam(value = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @ApiParam(value = \"\") @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " @ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity", "getPetById(" - + " @ApiParam(value = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity"), + + " @ApiParam(value = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(@ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( @@ -2643,14 +2644,14 @@ public void reactiveWithHttpRequestContextInterfaceOnlyAnnotationNoneDelegate() Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " exchange: org.springframework.web.server.ServerWebExchange" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " exchange: org.springframework.web.server.ServerWebExchange" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(exchange: org.springframework.web.server.ServerWebExchange): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( @@ -2682,12 +2683,12 @@ public void reactiveWithoutHttpRequestContextInterfaceOnlyAnnotationNoneDelegate Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( @@ -2719,14 +2720,14 @@ public void nonReactiveWithHttpRequestContextInterfaceOnlyAnnotationSwaggerDeleg Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @Parameter(description = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @Parameter(description = \"\", `in` = ParameterIn.HEADER) @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " @Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity", + + " @Parameter(description = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @Parameter(description = \"\", `in` = ParameterIn.HEADER) @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " @Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity", "getPetById(" - + " @Parameter(description = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity"), + + " @Parameter(description = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(@Parameter(hidden = true) request: javax.servlet.http.HttpServletRequest): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( @@ -2758,14 +2759,14 @@ public void nonReactiveWithHttpRequestContextInterfaceOnlyAnnotationSwagger1Dele Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @ApiParam(value = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @ApiParam(value = \"\") @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " @ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity", + + " @ApiParam(value = \"Pet id to delete\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @ApiParam(value = \"\") @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " @ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity", "getPetById(" - + " @ApiParam(value = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," - + " @ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity"), + + " @ApiParam(value = \"ID of pet to return\", required = true) @PathVariable(\"petId\") petId: kotlin.Long," + + " @ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(@ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( @@ -2797,14 +2798,14 @@ public void nonReactiveWithHttpRequestContextInterfaceOnlyAnnotationNoneDelegate Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," - + " request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?," + + " request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " request: javax.servlet.http.HttpServletRequest" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " request: javax.servlet.http.HttpServletRequest" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(request: javax.servlet.http.HttpServletRequest): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( @@ -2836,12 +2837,12 @@ public void nonReactiveWithoutHttpRequestContextInterfaceOnlyAnnotationNoneDeleg Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" + + " ): ResponseEntity", "getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "logoutUser(): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/PetApiDelegate.kt"), List.of( @@ -2873,13 +2874,13 @@ public void reactiveWithoutResponseEntity() throws Exception { root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "@ResponseStatus(HttpStatus.BAD_REQUEST)", "suspend fun deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" - + " ): Unit", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" + + " ): Unit", "@ResponseStatus(HttpStatus.OK)", "suspend fun getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long" - + " ): Pet"), + + " @PathVariable(\"petId\") petId: kotlin.Long" + + " ): Pet"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "@ResponseStatus(HttpStatus.OK)", "suspend fun logoutUser(): Unit" @@ -2911,13 +2912,13 @@ public void nonReactiveWithoutResponseEntity() throws Exception { root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "@ResponseStatus(HttpStatus.BAD_REQUEST)", "fun deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" - + " ): Unit", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" + + " ): Unit", "@ResponseStatus(HttpStatus.OK)", "fun getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long" - + " ): Pet"), + + " @PathVariable(\"petId\") petId: kotlin.Long" + + " ): Pet"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "@ResponseStatus(HttpStatus.OK)", "fun logoutUser(): Unit" @@ -2956,12 +2957,12 @@ public void reactiveWithResponseEntity() throws Exception { Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "suspend fun deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" + + " ): ResponseEntity", "suspend fun getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "suspend fun logoutUser(): ResponseEntity" ), @@ -2998,12 +2999,12 @@ public void nonReactiveWithResponseEntity() throws Exception { Map.of( root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"), List.of( "fun deletePet(" - + " @PathVariable(\"petId\") petId: kotlin.Long," - + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" - + " ): ResponseEntity", + + " @PathVariable(\"petId\") petId: kotlin.Long," + + " @RequestHeader(value = \"api_key\", required = false) apiKey: kotlin.String?" + + " ): ResponseEntity", "fun getPetById(" - + " @PathVariable(\"petId\") petId: kotlin.Long" - + " ): ResponseEntity"), + + " @PathVariable(\"petId\") petId: kotlin.Long" + + " ): ResponseEntity"), root.resolve("src/main/kotlin/org/openapitools/api/UserApi.kt"), List.of( "fun logoutUser(): ResponseEntity" ), @@ -3303,8 +3304,8 @@ public void testValidationsInQueryParams_issue21238_Api_Delegate() throws IOExce @DataProvider public Object[][] issue17997DocumentationProviders() { - return new Object[][] { - { DocumentationProviderFeatures.DocumentationProvider.SPRINGDOC.name(), + return new Object[][]{ + {DocumentationProviderFeatures.DocumentationProvider.SPRINGDOC.name(), (Consumer) outputPath -> assertFileContains( outputPath, @@ -3901,17 +3902,17 @@ public void springPaginatedWithSpringDocUsesPageableAsQueryParam() throws Except Assert.assertTrue(pageableAsQueryParamPos > 0, "@PageableAsQueryParam should be present before method"); Assert.assertTrue(requestMappingPos > pageableAsQueryParamPos, - "@PageableAsQueryParam should appear before @RequestMapping"); + "@PageableAsQueryParam should appear before @RequestMapping"); // Verify page, size, sort parameters are NOT in the method signature String methodSignature = content.substring(findPetsByStatusStart, - content.indexOf("): ResponseEntity", findPetsByStatusStart)); + content.indexOf("): ResponseEntity", findPetsByStatusStart)); Assert.assertFalse(methodSignature.contains("page:"), - "page parameter should be removed from method signature"); + "page parameter should be removed from method signature"); Assert.assertFalse(methodSignature.contains("size:") && methodSignature.contains("@RequestParam"), - "size query parameter should be removed from method signature"); + "size query parameter should be removed from method signature"); Assert.assertFalse(methodSignature.contains("sort:"), - "sort parameter should be removed from method signature"); + "sort parameter should be removed from method signature"); } @Test @@ -3962,11 +3963,11 @@ public void springPaginatedWithSpringDocPrependsToExistingAnnotation() throws Ex // Verify @PageableAsQueryParam comes before @Validated (prepended) Assert.assertTrue(pageableAsQueryParamPos < validatedPos, - "@PageableAsQueryParam should be prepended (appear before) existing @Validated annotation"); + "@PageableAsQueryParam should be prepended (appear before) existing @Validated annotation"); // Verify both annotations come before @RequestMapping Assert.assertTrue(validatedPos < requestMappingPos, - "Both annotations should appear before @RequestMapping"); + "Both annotations should appear before @RequestMapping"); // Verify the Pageable parameter still has @Parameter(hidden = true) assertFileContains(petApi.toPath(), "@Parameter(hidden = true) pageable: Pageable"); @@ -4006,15 +4007,15 @@ public void springPaginatedWithSpringDocPrependsToExistingAnnotationArray() thro // Verify @PageableAsQueryParam comes first (prepended to the array) Assert.assertTrue(pageableAsQueryParamPos < validatedPos, - "@PageableAsQueryParam should be prepended (appear before) @Validated annotation"); + "@PageableAsQueryParam should be prepended (appear before) @Validated annotation"); // Verify the original array order is preserved after @PageableAsQueryParam Assert.assertTrue(validatedPos < preAuthorizePos, - "@Validated should appear before @PreAuthorize (original array order preserved)"); + "@Validated should appear before @PreAuthorize (original array order preserved)"); // Verify all annotations come before @RequestMapping Assert.assertTrue(preAuthorizePos < requestMappingPos, - "All annotations should appear before @RequestMapping"); + "All annotations should appear before @RequestMapping"); // Verify the Pageable parameter still has @Parameter(hidden = true) assertFileContains(petApi.toPath(), "@Parameter(hidden = true) pageable: Pageable"); @@ -4041,11 +4042,11 @@ public void springPaginatedMixedOperations() throws Exception { // Verify addPet doesn't have pageable (it has body param only) String content = Files.readString(petApi.toPath()); String addPetMethod = content.substring( - content.indexOf("fun addPet("), - content.indexOf(")", content.indexOf("fun addPet(")) + 1 + content.indexOf("fun addPet("), + content.indexOf(")", content.indexOf("fun addPet(")) + 1 ); Assert.assertFalse(addPetMethod.contains("pageable"), - "addPet should not have pageable parameter"); + "addPet should not have pageable parameter"); } @Test @@ -4231,6 +4232,8 @@ public void generatePageableConstraintValidationGeneratesValidPageableFile() thr assertFileContains(validPageableFile.toPath(), "class PageableConstraintValidator"); assertFileContains(validPageableFile.toPath(), "val maxSize: Int"); assertFileContains(validPageableFile.toPath(), "val maxPage: Int"); + assertFileContains(validPageableFile.toPath(), "val minSize: Int"); + assertFileContains(validPageableFile.toPath(), "val minPage: Int"); assertFileContains(validPageableFile.toPath(), "NO_LIMIT"); } @@ -4267,7 +4270,49 @@ public void generatePageableConstraintValidationDoesNotGenerateFileWhenBeanValid // ========== AUTO X-SPRING-PAGINATED TESTS ========== - // ========== GENERATE SORT VALIDATION TESTS ========== + @Test + public void generatePageableConstraintValidationResolvesMaximumFromAllOfRef() throws Exception { + Map additionalProperties = new HashMap<>(); + additionalProperties.put(USE_TAGS, "true"); + additionalProperties.put(INTERFACE_ONLY, "true"); + additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); + additionalProperties.put(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "true"); + + Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); + + File petApi = files.get("PetApi.kt"); + String content = Files.readString(petApi.toPath()); + + // findPetsWithSizeConstraintFromAllOfRef: maximum: 75 is on the referenced schema only + int methodStart = content.indexOf("fun findPetsWithSizeConstraintFromAllOfRef("); + Assert.assertTrue(methodStart >= 0, "findPetsWithSizeConstraintFromAllOfRef method should exist"); + String paramBlock = content.substring(methodStart, Math.min(content.length(), methodStart + 500)); + Assert.assertTrue(paramBlock.contains("@ValidPageable(maxSize = 75)"), + "@ValidPageable(maxSize = 75) should be resolved from allOf $ref schema"); + } + + @Test + public void generatePageableConstraintValidationResolvesMinimumFromAllOfRef() throws Exception { + Map additionalProperties = new HashMap<>(); + additionalProperties.put(USE_TAGS, "true"); + additionalProperties.put(INTERFACE_ONLY, "true"); + additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); + additionalProperties.put(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "true"); + + Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); + + File petApi = files.get("PetApi.kt"); + String content = Files.readString(petApi.toPath()); + + // findPetsWithMinSizeConstraintFromAllOfRef: minimum: 5 is on the referenced schema only + int methodStart = content.indexOf("fun findPetsWithMinSizeConstraintFromAllOfRef("); + Assert.assertTrue(methodStart >= 0, "findPetsWithMinSizeConstraintFromAllOfRef method should exist"); + String paramBlock = content.substring(methodStart, Math.min(content.length(), methodStart + 500)); + Assert.assertTrue(paramBlock.contains("@ValidPageable(minSize = 5)"), + "@ValidPageable(minSize = 5) should be resolved from allOf $ref schema"); + } + + // ========== AUTO X-SPRING-PAGINATED TESTS ========== @Test public void generateSortValidationAddsAnnotationForExplicitPaginated() throws Exception { @@ -4346,7 +4391,7 @@ public void generateSortValidationDoesNotAnnotateNonPaginatedOperation() throws Assert.assertTrue(methodStart >= 0, "findPetsNonPaginatedWithSortEnum method should exist"); String methodBlock = content.substring(Math.max(0, methodStart - 500), methodStart); Assert.assertFalse(methodBlock.contains("@ValidSort"), - "Non-paginated operation should not have @ValidSort even if sort param has enum values"); + "Non-paginated operation should not have @ValidSort even if sort param has enum values"); } @Test @@ -4367,7 +4412,7 @@ public void generateSortValidationDoesNotAnnotateWhenSortHasNoEnum() throws Exce Assert.assertTrue(methodStart >= 0, "findPetsWithoutSortEnum method should exist"); String methodBlock = content.substring(Math.max(0, methodStart - 500), methodStart); Assert.assertFalse(methodBlock.contains("@ValidSort"), - "Paginated operation with non-enum sort should not have @ValidSort"); + "Paginated operation with non-enum sort should not have @ValidSort"); } @Test @@ -4594,7 +4639,7 @@ public void pageableDefaultsGeneratesBothAnnotationsWhenAllDefaultsPresent() thr Assert.assertTrue(methodBlock.contains("@PageableDefault(page = 0, size = 10)"), "findPetsWithAllDefaults should have @PageableDefault(page = 0, size = 10)"); Assert.assertTrue(methodBlock.contains( - "@SortDefault.SortDefaults(SortDefault(sort = [\"name\"], direction = Sort.Direction.DESC), SortDefault(sort = [\"id\"], direction = Sort.Direction.ASC))"), + "@SortDefault.SortDefaults(SortDefault(sort = [\"name\"], direction = Sort.Direction.DESC), SortDefault(sort = [\"id\"], direction = Sort.Direction.ASC))"), "findPetsWithAllDefaults should have @SortDefault.SortDefaults with both fields"); } @@ -4645,19 +4690,19 @@ public void autoXSpringPaginatedDetectsAllThreeParams() throws Exception { // Should have pageable parameter Assert.assertTrue(methodSignature.contains("pageable: Pageable"), - "findPetsWithAutoDetect should have pageable parameter when autoXSpringPaginated is enabled"); + "findPetsWithAutoDetect should have pageable parameter when autoXSpringPaginated is enabled"); // Should NOT have page, size, sort query params (they should be removed) Assert.assertFalse(methodSignature.contains("page:"), - "page query param should be removed when pageable is added"); + "page query param should be removed when pageable is added"); Assert.assertFalse(methodSignature.contains("size:"), - "size query param should be removed when pageable is added"); + "size query param should be removed when pageable is added"); Assert.assertFalse(methodSignature.contains("sort:"), - "sort query param should be removed when pageable is added"); + "sort query param should be removed when pageable is added"); // Should still have the status parameter Assert.assertTrue(methodSignature.contains("status:"), - "status parameter should remain"); + "status parameter should remain"); } @Test @@ -4680,13 +4725,13 @@ public void autoXSpringPaginatedNoDetectionWhenMissingPage() throws Exception { String methodSignature = content.substring(methodStart, methodEnd); Assert.assertFalse(methodSignature.contains("pageable: Pageable"), - "findPetsMissingPage should NOT have pageable when 'page' param is missing"); + "findPetsMissingPage should NOT have pageable when 'page' param is missing"); // Should still have the other params Assert.assertTrue(methodSignature.contains("size:"), - "size param should remain"); + "size param should remain"); Assert.assertTrue(methodSignature.contains("sort:"), - "sort param should remain"); + "sort param should remain"); } @Test @@ -4709,13 +4754,13 @@ public void autoXSpringPaginatedNoDetectionWhenMissingSize() throws Exception { String methodSignature = content.substring(methodStart, methodEnd); Assert.assertFalse(methodSignature.contains("pageable: Pageable"), - "findPetsMissingSize should NOT have pageable when 'size' param is missing"); + "findPetsMissingSize should NOT have pageable when 'size' param is missing"); // Should still have the other params Assert.assertTrue(methodSignature.contains("page:"), - "page param should remain"); + "page param should remain"); Assert.assertTrue(methodSignature.contains("sort:"), - "sort param should remain"); + "sort param should remain"); } @Test @@ -4738,13 +4783,13 @@ public void autoXSpringPaginatedNoDetectionWhenMissingSort() throws Exception { String methodSignature = content.substring(methodStart, methodEnd); Assert.assertFalse(methodSignature.contains("pageable: Pageable"), - "findPetsMissingSort should NOT have pageable when 'sort' param is missing"); + "findPetsMissingSort should NOT have pageable when 'sort' param is missing"); // Should still have the other params Assert.assertTrue(methodSignature.contains("page:"), - "page param should remain"); + "page param should remain"); Assert.assertTrue(methodSignature.contains("size:"), - "size param should remain"); + "size param should remain"); } @Test @@ -4767,15 +4812,15 @@ public void autoXSpringPaginatedManualFalseTakesPrecedence() throws Exception { String methodSignature = content.substring(methodStart, methodEnd); Assert.assertFalse(methodSignature.contains("pageable: Pageable"), - "findPetsManualFalse should NOT have pageable when x-spring-paginated is explicitly set to false"); + "findPetsManualFalse should NOT have pageable when x-spring-paginated is explicitly set to false"); // Should still have all three params Assert.assertTrue(methodSignature.contains("page:"), - "page param should remain when x-spring-paginated: false"); + "page param should remain when x-spring-paginated: false"); Assert.assertTrue(methodSignature.contains("size:"), - "size param should remain when x-spring-paginated: false"); + "size param should remain when x-spring-paginated: false"); Assert.assertTrue(methodSignature.contains("sort:"), - "sort param should remain when x-spring-paginated: false"); + "sort param should remain when x-spring-paginated: false"); } @Test @@ -4798,11 +4843,11 @@ public void autoXSpringPaginatedCaseSensitiveMatching() throws Exception { String methodSignature = content.substring(methodStart, methodEnd); Assert.assertFalse(methodSignature.contains("pageable: Pageable"), - "findPetsCaseSensitive should NOT have pageable with capitalized param names (case-sensitive)"); + "findPetsCaseSensitive should NOT have pageable with capitalized param names (case-sensitive)"); // Should still have all three params with capital letters Assert.assertTrue(methodSignature.contains("page:") || methodSignature.contains("Page:"), - "Page param should remain"); + "Page param should remain"); } @Test @@ -4814,10 +4859,10 @@ public void autoXSpringPaginatedOnlyForSpringBoot() throws Exception { // Test with spring-cloud library (should NOT auto-detect) Map files = generateFromContract( - "src/test/resources/3_0/spring/petstore-auto-paginated.yaml", - additionalProperties, - new HashMap<>(), - configurator -> configurator.setLibrary("spring-cloud") + "src/test/resources/3_0/spring/petstore-auto-paginated.yaml", + additionalProperties, + new HashMap<>(), + configurator -> configurator.setLibrary("spring-cloud") ); File petApi = files.get("PetApiClient.kt"); @@ -4826,7 +4871,7 @@ public void autoXSpringPaginatedOnlyForSpringBoot() throws Exception { // For spring-cloud, should NOT have Pageable even with auto-detect enabled Assert.assertFalse(content.contains("pageable: Pageable"), - "spring-cloud library should NOT auto-detect pageable (needs actual query params for HTTP)"); + "spring-cloud library should NOT auto-detect pageable (needs actual query params for HTTP)"); // Should have all three query params int methodStart = content.indexOf("fun findPetsWithAutoDetect("); @@ -4835,11 +4880,45 @@ public void autoXSpringPaginatedOnlyForSpringBoot() throws Exception { String methodSignature = content.substring(methodStart, methodEnd); Assert.assertTrue(methodSignature.contains("page") || methodSignature.contains("@Query"), - "spring-cloud should keep query parameters"); + "spring-cloud should keep query parameters"); } } } + @Test + public void explicitXSpringPaginatedIgnoredForSpringCloud() throws Exception { + // When x-spring-paginated: true is set explicitly in the spec but the library is spring-cloud, + // the extension must be stripped so the template does not emit "pageable: Pageable". + // Individual page/size/sort @RequestParam args from the spec should remain. + Map additionalProperties = new HashMap<>(); + additionalProperties.put(USE_TAGS, "true"); + additionalProperties.put(DOCUMENTATION_PROVIDER, "springdoc"); + additionalProperties.put(INTERFACE_ONLY, "true"); + additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-with-spring-pageable.yaml", + additionalProperties, + new HashMap<>(), + configurator -> configurator.setLibrary("spring-cloud") + ); + + File petApi = files.get("PetApi.kt"); + Assert.assertNotNull(petApi, "PetApi.kt should be generated for spring-cloud library"); + + // No Pageable type or its import must appear for spring-cloud + assertFileNotContains(petApi.toPath(), + "import org.springframework.data.domain.Pageable", + "pageable: Pageable"); + + // findPetsByStatus must exist without a Pageable parameter + assertFileContains(petApi.toPath(), "fun findPetsByStatus("); + + // findPetsByTags must retain all individual query params defined alongside x-spring-paginated + assertFileContains(petApi.toPath(), "@RequestParam(value = \"page\""); + assertFileContains(petApi.toPath(), "@RequestParam(value = \"sort\""); + } + @Test public void autoXSpringPaginatedDisabledByDefault() throws Exception { Map additionalProperties = new HashMap<>(); @@ -4860,15 +4939,15 @@ public void autoXSpringPaginatedDisabledByDefault() throws Exception { String methodSignature = content.substring(methodStart, methodEnd); Assert.assertFalse(methodSignature.contains("pageable: Pageable"), - "Should NOT have pageable when autoXSpringPaginated is not enabled (default: false)"); + "Should NOT have pageable when autoXSpringPaginated is not enabled (default: false)"); // Should have all three query params Assert.assertTrue(methodSignature.contains("page:"), - "page param should remain when auto-detect is disabled"); + "page param should remain when auto-detect is disabled"); Assert.assertTrue(methodSignature.contains("size:"), - "size param should remain when auto-detect is disabled"); + "size param should remain when auto-detect is disabled"); Assert.assertTrue(methodSignature.contains("sort:"), - "sort param should remain when auto-detect is disabled"); + "sort param should remain when auto-detect is disabled"); } @Test @@ -4891,15 +4970,15 @@ public void autoXSpringPaginatedWorksWithManualTrue() throws Exception { String methodSignature = content.substring(methodStart, methodEnd); Assert.assertTrue(methodSignature.contains("pageable: Pageable"), - "findPetsManualTrue should have pageable (manual x-spring-paginated: true)"); + "findPetsManualTrue should have pageable (manual x-spring-paginated: true)"); // Query params should be removed Assert.assertFalse(methodSignature.contains("page:"), - "page param should be removed"); + "page param should be removed"); Assert.assertFalse(methodSignature.contains("size:"), - "size param should be removed"); + "size param should be removed"); Assert.assertFalse(methodSignature.contains("sort:"), - "sort param should be removed"); + "sort param should be removed"); } @Test @@ -4922,7 +5001,7 @@ public void autoXSpringPaginatedNoParamsDoesNotDetect() throws Exception { String methodSignature = content.substring(methodStart, methodEnd); Assert.assertFalse(methodSignature.contains("pageable: Pageable"), - "findPetsNoParams should NOT have pageable when there are no pagination params"); + "findPetsNoParams should NOT have pageable when there are no pagination params"); } @Test @@ -5530,12 +5609,12 @@ public void shouldAddParameterWithInHeaderWhenImplicitHeadersIsTrue() throws IOE String methodSignature = matcher.group(); Assert.assertFalse(methodSignature.contains("testHeader"), - "Header param 'testHeader' should NOT be in method signature when implicitHeaders=true"); + "Header param 'testHeader' should NOT be in method signature when implicitHeaders=true"); Assert.assertTrue(content.contains("@Parameters"), - "@Parameters annotation should be present"); + "@Parameters annotation should be present"); Assert.assertTrue(content.contains("testHeader"), - "Header name 'testHeader' should appear in the annotation"); + "Header name 'testHeader' should appear in the annotation"); } // ------------------------------------------------------------------------- @@ -5653,7 +5732,9 @@ public void substituteGenericPagedModel_doesNotReplaceNonPagedReturnType() throw assertThat(content).doesNotContain("PagedModel"); } - /** Common properties shared by all substituteGenericPagedModel tests for Kotlin Spring. */ + /** + * Common properties shared by all substituteGenericPagedModel tests for Kotlin Spring. + */ private Map commonKotlinPagedModelProps() { Map props = new HashMap<>(); props.put(INTERFACE_ONLY, "true"); @@ -5664,7 +5745,9 @@ private Map commonKotlinPagedModelProps() { return props; } - /** Properties with annotations disabled — triggers model suppression. */ + /** + * Properties with annotations disabled — triggers model suppression. + */ private Map noAnnotationKotlinPagedModelProps() { Map props = commonKotlinPagedModelProps(); props.put(DOCUMENTATION_PROVIDER, "none"); @@ -5811,7 +5894,9 @@ public void substituteGenericPagedModel_springDeclarativeHttpInterface_respectsC assertThat(content).contains("import com.example.custom.MyPagedModel"); } - /** Common properties for substituteGenericPagedModel tests using spring-declarative-http-interface. */ + /** + * Common properties for substituteGenericPagedModel tests using spring-declarative-http-interface. + */ private Map commonDeclarativeHttpInterfacePagedModelProps() { Map props = new HashMap<>(); props.put(USE_TAGS, "true"); @@ -5880,7 +5965,9 @@ public void substituteGenericPagedModel_springCloud_respectsCustomImportMappingC assertThat(content).contains("import com.example.custom.MyPagedModel"); } - /** Common properties for substituteGenericPagedModel tests using spring-cloud. */ + /** + * Common properties for substituteGenericPagedModel tests using spring-cloud. + */ private Map springCloudKotlinPagedModelProps() { Map props = new HashMap<>(); props.put(USE_TAGS, "true"); @@ -5888,6 +5975,206 @@ private Map springCloudKotlinPagedModelProps() { return props; } + // ------------------------------------------------------------------------- + // substituteGenericPagedModel — modelNameSuffix / modelNamePrefix + // ------------------------------------------------------------------------- + + @Test + public void substituteGenericPagedModel_withModelNameSuffix_replacesReturnType() throws IOException { + // When modelNameSuffix is set the returnBaseType includes the suffix, + // so the registry lookup must also use the suffix-applied key. + Map props = commonKotlinPagedModelProps(); + props.put("modelNameSuffix", "Dto"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", props); + + // listUsers returns UserPage → suffix applied → UserPageDto → replaced with PagedModel + File userApi = files.get("UserApi.kt"); + assertThat(userApi).isNotNull(); + String content = Files.readString(userApi.toPath()); + assertThat(content).contains("PagedModel"); + } + + @Test + public void substituteGenericPagedModel_withModelNamePrefix_replacesReturnType() throws IOException { + // When modelNamePrefix is set the returnBaseType includes the prefix, + // so the registry lookup must also use the prefix-applied key. + Map props = commonKotlinPagedModelProps(); + props.put("modelNamePrefix", "My"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", props); + + // listUsers returns UserPage → prefix applied → MyUserPage → replaced with PagedModel + File userApi = files.get("UserApi.kt"); + assertThat(userApi).isNotNull(); + String content = Files.readString(userApi.toPath()); + assertThat(content).contains("PagedModel"); + } + + @Test + public void substituteGenericPagedModel_withModelNameSuffix_suppressesPagedSchemasWhenNoAnnotations() + throws IOException { + // Verify schema suppression also works correctly under modelNameSuffix + // (objs keys are suffix-applied, registry keys must match them). + Map props = noAnnotationKotlinPagedModelProps(); + props.put("modelNameSuffix", "Dto"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", props); + + assertThat(files).doesNotContainKey("UserPageDto.kt"); + assertThat(files).doesNotContainKey("OrderPageDto.kt"); + assertThat(files).doesNotContainKey("PetPageAllOfDto.kt"); + } + + // ------------------------------------------------------------------------- + // genericPatterns — modelNameSuffix / modelNamePrefix + // ------------------------------------------------------------------------- + + /** + * Builds common test props for Kotlin genericPatterns feature tests. + * Uses annotationLibrary=none so that suppression is active. + */ + private Map kotlinGenericPatternsProps() { + Map props = new HashMap<>(); + props.put(INTERFACE_ONLY, "true"); + props.put(SKIP_DEFAULT_INTERFACE, "true"); + props.put(USE_TAGS, "true"); + props.put(USE_SPRING_BOOT3, "true"); + props.put(DOCUMENTATION_PROVIDER, "none"); + props.put(ANNOTATION_LIBRARY, "none"); + + // Pattern: suffix=Response, slot=data, Mode B (simple class name → generate) + Map responsePattern = new HashMap<>(); + responsePattern.put("suffix", "Response"); + responsePattern.put("genericClass", "ApiResponse"); + responsePattern.put("slot", "data"); + + props.put(GENERIC_PATTERNS, java.util.Arrays.asList(responsePattern)); + return props; + } + + @Test + public void genericPatterns_withModelNameSuffix_replacesReturnType() throws IOException { + // When modelNameSuffix is set, op.returnBaseType includes the suffix (e.g. "UserResponseDto"). + // The registry must be re-keyed with toModelName() so the lookup succeeds. + Map props = kotlinGenericPatternsProps(); + props.put("modelNameSuffix", "Dto"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", props); + + // getUserResponse returns UserResponse → suffix applied → UserResponseDto → replaced with ApiResponse + File responseApi = files.get("ResponseApi.kt"); + assertThat(responseApi).isNotNull(); + String content = Files.readString(responseApi.toPath()); + assertThat(content).contains("ApiResponse"); + } + + @Test + public void genericPatterns_withModelNamePrefix_replacesReturnType() throws IOException { + // When modelNamePrefix is set, op.returnBaseType includes the prefix (e.g. "MyUserResponse"). + // The registry must be re-keyed with toModelName() so the lookup succeeds. + Map props = kotlinGenericPatternsProps(); + props.put("modelNamePrefix", "My"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", props); + + // getUserResponse returns UserResponse → prefix applied → MyUserResponse → replaced with ApiResponse + File responseApi = files.get("ResponseApi.kt"); + assertThat(responseApi).isNotNull(); + String content = Files.readString(responseApi.toPath()); + assertThat(content).contains("ApiResponse"); + } + + @Test + public void genericPatterns_withModelNameSuffix_suppressesConcreteSchemaClasses() throws IOException { + // Verify schema suppression works with modelNameSuffix: + // objs keys are raw schema names; inst.schemaName (raw) must be used for removal. + Map props = kotlinGenericPatternsProps(); + props.put("modelNameSuffix", "Dto"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", props); + + // Concrete wrapper schemas are suppressed (note: file name uses raw schema + suffix) + assertThat(files).doesNotContainKey("UserResponseDto.kt"); + assertThat(files).doesNotContainKey("PetResponseDto.kt"); + assertThat(files).doesNotContainKey("OrderResponseDto.kt"); + } + + // ------------------------------------------------------------------------- + // genericPatterns — property-level substitution (Scenario A) + // ------------------------------------------------------------------------- + + @Test + public void genericPatterns_substitutesPropertyTypeRef() throws IOException { + // Scenario A: OrderDetails.userResult: $ref UserResponse → ApiResponse + Map props = kotlinGenericPatternsProps(); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", props); + + File orderDetails = files.get("OrderDetails.kt"); + assertThat(orderDetails).isNotNull(); + String content = Files.readString(orderDetails.toPath()); + assertThat(content).contains("ApiResponse"); + assertThat(content).doesNotContain("UserResponse userResult"); + assertThat(content).doesNotContain("UserResponse?"); + } + + // ------------------------------------------------------------------------- + // genericPatterns — recursive type-arg expansion (Scenario C) + // ------------------------------------------------------------------------- + + @Test + public void genericPatterns_recursiveTypeArgInReturnType() throws IOException { + // Scenario C: UserResponsePage → Page> + // Requires both the Response pattern (to detect UserResponse) and the Page pattern + // (to detect UserResponsePage as Page where T=UserResponse). + Map props = kotlinGenericPatternsProps(); + + // Add the Page pattern (Mode A: FQN generic class, slotArray=content) + Map pagePattern = new HashMap<>(); + pagePattern.put("suffix", "Page"); + pagePattern.put("genericClass", "org.springframework.data.domain.Page"); + pagePattern.put("slotArray", "content"); + List> patterns = new java.util.ArrayList<>((List>) props.get(GENERIC_PATTERNS)); + patterns.add(pagePattern); + props.put(GENERIC_PATTERNS, patterns); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", props); + + File pageApi = files.get("PageApi.kt"); + assertThat(pageApi).isNotNull(); + String content = Files.readString(pageApi.toPath()); + assertThat(content).contains("Page>"); + } + + // ------------------------------------------------------------------------- + // genericPatterns — suppression safety check (Scenario E) + // ------------------------------------------------------------------------- + + @Test + public void genericPatterns_suppressionSafety_keepsSchemaWhenExtended() throws IOException { + // Scenario E: ExtendedUserResponse uses allOf composition (no discriminator → no Kotlin + // inheritance, model.parent = null). UserResponse CAN be safely suppressed. + Map props = kotlinGenericPatternsProps(); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics-inheritance.yaml", props); + + // UserResponse IS correctly suppressed (composition, not inheritance → safe) + assertThat(files).doesNotContainKey("UserResponse.kt"); + // ExtendedUserResponse is still generated + assertThat(files).containsKey("ExtendedUserResponse.kt"); + } + + @Test(description = "oneOf with discriminator generates thin sealed interface with Jackson annotations") public void testOneOfWithDiscriminatorGeneratesThinInterface() throws IOException { File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); @@ -5895,7 +6182,9 @@ public void testOneOfWithDiscriminatorGeneratesThinInterface() throws IOExceptio new DefaultGenerator().opts(new ClientOptInput() .openAPI(new OpenAPIParser().readLocation("src/test/resources/3_0/kotlin/polymorphism-oneof-discriminator.yaml", null, new ParseOptions()).getOpenAPI()) - .config(new KotlinSpringServerCodegen() {{ setOutputDir(output.getAbsolutePath()); }})) + .config(new KotlinSpringServerCodegen() {{ + setOutputDir(output.getAbsolutePath()); + }})) .generate(); String outputPath = output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/model"; @@ -5921,7 +6210,9 @@ public void testOneOfSubtypesImplementInterface() throws IOException { new DefaultGenerator().opts(new ClientOptInput() .openAPI(new OpenAPIParser().readLocation("src/test/resources/3_0/kotlin/polymorphism-oneof-discriminator.yaml", null, new ParseOptions()).getOpenAPI()) - .config(new KotlinSpringServerCodegen() {{ setOutputDir(output.getAbsolutePath()); }})) + .config(new KotlinSpringServerCodegen() {{ + setOutputDir(output.getAbsolutePath()); + }})) .generate(); String outputPath = output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/model"; @@ -5972,7 +6263,9 @@ public void testOneOf31SpecWithDiscriminator() throws IOException { new DefaultGenerator().opts(new ClientOptInput() .openAPI(new OpenAPIParser().readLocation("src/test/resources/3_1/polymorphism-and-discriminator.yaml", null, new ParseOptions()).getOpenAPI()) - .config(new KotlinSpringServerCodegen() {{ setOutputDir(output.getAbsolutePath()); }})) + .config(new KotlinSpringServerCodegen() {{ + setOutputDir(output.getAbsolutePath()); + }})) .generate(); String outputPath = output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/model"; @@ -6009,7 +6302,9 @@ public void testOneOfRefEnumDiscriminatorResolvesType() throws IOException { new DefaultGenerator().opts(new ClientOptInput() .openAPI(new OpenAPIParser().readLocation("src/test/resources/3_0/kotlin/polymorphism-oneof-enum-discriminator.yaml", null, new ParseOptions()).getOpenAPI()) - .config(new KotlinSpringServerCodegen() {{ setOutputDir(output.getAbsolutePath()); }})) + .config(new KotlinSpringServerCodegen() {{ + setOutputDir(output.getAbsolutePath()); + }})) .generate(); String outputPath = output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/model"; @@ -6107,21 +6402,76 @@ public void testSealedResponseInterfacesWithDeclarativeHttpInterface() throws IO "): ResponseEntity"); } + // useEnumValueInterface tests + // ------------------------------------------------------------------------- + + @Test + public void useEnumValueInterface_isDisabledByDefault() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", new HashMap<>()); + + assertThat(files).doesNotContainKey("ValuedEnum.kt"); + assertFileNotContains(files.get("OrderStatus.kt").toPath(), ": ValuedEnum<"); + } + @Test - public void schemaMappingWithNullableAllOfRendersNullableKotlinProperty() throws IOException { - // When a schema is substituted via schemaMapping and a property wraps it with - // "nullable: true + allOf: [$ref]", the Kotlin Spring generator must render the - // property as MappedType? (nullable with the FQN from the mapping). + public void useEnumValueInterface_generatesInterface() throws IOException { Map files = generateFromContract( - "src/test/resources/3_0/schema-mapping-nullable-allof.yaml", + "src/test/resources/3_0/spring/enum-value-interface.yaml", + Map.of(USE_ENUM_VALUE_INTERFACE, "true")); + + assertThat(files).containsKey("ValuedEnum.kt"); + assertFileContains(files.get("ValuedEnum.kt").toPath(), "interface ValuedEnum"); + } + + @Test + public void useEnumValueInterface_topLevelEnumImplementsInterface() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", + Map.of(USE_ENUM_VALUE_INTERFACE, "true")); + + assertFileContains(files.get("OrderStatus.kt").toPath(), + ": ValuedEnum", + "override val value", + "import org.openapitools.configuration.ValuedEnum"); + } + + @Test + public void useEnumValueInterface_inlineEnumImplementsInterface() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", + Map.of(USE_ENUM_VALUE_INTERFACE, "true")); + + assertFileContains(files.get("Order.kt").toPath(), + ": ValuedEnum", + "override val value", + "import org.openapitools.configuration.ValuedEnum"); + } + + @Test + public void useEnumValueInterface_noFileGeneratedWithCustomImportMapping() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", + Map.of(USE_ENUM_VALUE_INTERFACE, "true"), new HashMap<>(), + configurator -> configurator + .addImportMapping("ValuedEnum", "com.example.custom.ValuedEnum")); + + assertThat(files).doesNotContainKey("ValuedEnum.kt"); + } + + @Test + public void useEnumValueInterface_customImportMappingUsedInGeneratedCode() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", + Map.of(USE_ENUM_VALUE_INTERFACE, "true"), new HashMap<>(), - configurator -> configurator.addSchemaMapping("ExternalModel", "com.example.ExternalModel")); + configurator -> configurator + .addImportMapping("ValuedEnum", "com.example.custom.ValuedEnum")); - File myObjectFile = files.get("MyObject.kt"); - assertThat(myObjectFile).isNotNull(); - String content = Files.readString(myObjectFile.toPath()); - assertThat(content).contains("com.example.ExternalModel?"); + assertFileContains(files.get("OrderStatus.kt").toPath(), + ": ValuedEnum", + "import com.example.custom.ValuedEnum"); } // ========== REQUIRED + NULLABLE 4-STATE TESTS ========== @@ -6234,6 +6584,7 @@ public void requiredNullable_scenario4_optionalNullable_withOpenApiNullable() th /** * Scenario 3 with Jackson 3 (Spring Boot 4): optional + non-nullable. + * * @JsonSetter / Nulls imports should come from com.fasterxml.jackson.annotation * (Jackson 3.x intentionally kept jackson-annotations at 2.x, same package). */ @@ -6428,7 +6779,7 @@ public void suspendFunctionsDefaultsToFalse() throws Exception { Path petApiPath = root.resolve("src/main/kotlin/org/openapitools/api/PetApi.kt"); String content = new String(Files.readAllBytes(petApiPath), java.nio.charset.StandardCharsets.UTF_8); Assert.assertFalse(content.contains("suspend fun"), - "suspend should not be present when suspendFunctions is not enabled"); + "suspend should not be present when suspendFunctions is not enabled"); } @Test @@ -6454,4 +6805,22 @@ public void suspendFunctionsWithServiceInterface() throws Exception { ) ); } + + + @Test + public void schemaMappingWithNullableAllOfRendersNullableKotlinProperty() throws IOException { + // When a schema is substituted via schemaMapping and a property wraps it with + // "nullable: true + allOf: [$ref]", the Kotlin Spring generator must render the + // property as MappedType? (nullable with the FQN from the mapping). + Map files = generateFromContract( + "src/test/resources/3_0/schema-mapping-nullable-allof.yaml", + new HashMap<>(), + new HashMap<>(), + configurator -> configurator.addSchemaMapping("ExternalModel", "com.example.ExternalModel")); + + File myObjectFile = files.get("MyObject.kt"); + assertThat(myObjectFile).isNotNull(); + String content = Files.readString(myObjectFile.toPath()); + assertThat(content).contains("com.example.ExternalModel?"); + } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/GenericSchemaScanUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/GenericSchemaScanUtilsTest.java new file mode 100644 index 000000000000..823b07696eec --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/GenericSchemaScanUtilsTest.java @@ -0,0 +1,1223 @@ +/* + * 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 io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.media.*; +import org.testng.annotations.Test; + +import java.util.*; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link GenericSchemaScanUtils}. + */ +public class GenericSchemaScanUtilsTest { + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static OpenAPI buildOpenAPI(Map schemas) { + OpenAPI openAPI = new OpenAPI(); + Components components = new Components(); + components.setSchemas(schemas); + openAPI.setComponents(components); + return openAPI; + } + + /** Returns a local $ref string. */ + private static String ref(String name) { + return "#/components/schemas/" + name; + } + + /** Builds a simple string-typed schema. */ + private static Schema stringSchema() { + return new StringSchema(); + } + + /** Builds a simple integer-typed schema. */ + private static Schema intSchema() { + return new IntegerSchema(); + } + + /** Builds a $ref schema. */ + private static Schema refSchema(String name) { + return new Schema<>().$ref(ref(name)); + } + + /** Builds an array schema whose items are a $ref. */ + private static Schema arrayRefSchema(String itemName) { + ArraySchema arr = new ArraySchema(); + arr.setItems(new Schema<>().$ref(ref(itemName))); + return arr; + } + + /** + * Builds an "ApiResponse-style" flat-object schema: + * data: $ref -> refTarget + * status: string + * message: string + * (required: data, status) + */ + private static Schema responseSchema(String dataRefTarget) { + ObjectSchema s = new ObjectSchema(); + Map props = new LinkedHashMap<>(); + props.put("data", refSchema(dataRefTarget)); + props.put("status", stringSchema()); + props.put("message", stringSchema()); + s.setProperties(props); + s.setRequired(Arrays.asList("data", "status")); + return s; + } + + /** + * Builds a flat-object "Page-style" schema: + * content: array of $ref -> itemRefTarget + * page: $ref -> PageMeta + * (required: content, page) + */ + private static Schema pageSchemaFlat(String itemRefTarget) { + ObjectSchema s = new ObjectSchema(); + Map props = new LinkedHashMap<>(); + props.put("content", arrayRefSchema(itemRefTarget)); + props.put("page", refSchema("PageMeta")); + s.setProperties(props); + s.setRequired(Arrays.asList("content", "page")); + return s; + } + + /** + * Builds an allOf "Page-style" schema: + * allOf: + * - $ref: PageMeta + * - type: object + * properties: + * content: array of $ref -> itemRefTarget + */ + private static Schema pageSchemaAllOf(String itemRefTarget) { + ComposedSchema s = new ComposedSchema(); + Schema pageMetaRef = new Schema<>().$ref(ref("PageMeta")); + ObjectSchema inline = new ObjectSchema(); + Map props = new LinkedHashMap<>(); + props.put("content", arrayRefSchema(itemRefTarget)); + inline.setProperties(props); + s.setAllOf(Arrays.asList(pageMetaRef, inline)); + return s; + } + + /** + * Builds an allOf "event-envelope" style schema: + * allOf: + * - $ref: BaseEvent + * - type: object + * properties: + * payload: $ref -> payloadRefTarget + */ + private static Schema eventSchemaAllOf(String payloadRefTarget) { + ComposedSchema s = new ComposedSchema(); + Schema baseEventRef = new Schema<>().$ref(ref("BaseEvent")); + ObjectSchema inline = new ObjectSchema(); + Map props = new LinkedHashMap<>(); + props.put("payload", refSchema(payloadRefTarget)); + inline.setProperties(props); + s.setAllOf(Arrays.asList(baseEventRef, inline)); + return s; + } + + /** Builds a "LogEntry-style" schema: data -> $ref, severity: string, timestamp: string. */ + private static Schema entrySchema(String dataRefTarget) { + ObjectSchema s = new ObjectSchema(); + Map props = new LinkedHashMap<>(); + props.put("data", refSchema(dataRefTarget)); + props.put("severity", stringSchema()); + props.put("timestamp", stringSchema()); + s.setProperties(props); + return s; + } + + /** Builds a GenericPatternConfig with suffix and slot. */ + private static GenericPatternConfig suffixSlotPattern(String suffix, String genericClass, String slot) { + GenericPatternConfig cfg = new GenericPatternConfig(); + cfg.suffix = suffix; + cfg.genericClass = genericClass; + cfg.slot = slot; + return cfg; + } + + /** Builds a GenericPatternConfig with suffix and slotArray. */ + private static GenericPatternConfig suffixSlotArrayPattern(String suffix, String genericClass, String slotArray) { + GenericPatternConfig cfg = new GenericPatternConfig(); + cfg.suffix = suffix; + cfg.genericClass = genericClass; + cfg.slotArray = slotArray; + return cfg; + } + + /** + * Builds a Result-style schema with two $ref slots: + * data -> dataRef, error -> errorRef, success: boolean + */ + private static Schema resultSchema(String dataRefTarget, String errorRefTarget) { + ObjectSchema s = new ObjectSchema(); + Map props = new LinkedHashMap<>(); + props.put("data", refSchema(dataRefTarget)); + props.put("error", refSchema(errorRefTarget)); + props.put("success", new BooleanSchema()); + s.setProperties(props); + s.setRequired(Collections.singletonList("data")); + return s; + } + + // ========================================================================= + // matchesPattern + // ========================================================================= + + @Test + public void matchesPattern_suffixMatch_returnsTrue() { + GenericPatternConfig cfg = new GenericPatternConfig(); + cfg.suffix = "Response"; + assertThat(GenericSchemaScanUtils.matchesPattern("UserResponse", cfg)).isTrue(); + assertThat(GenericSchemaScanUtils.matchesPattern("PetResponse", cfg)).isTrue(); + } + + @Test + public void matchesPattern_suffixExactNameOnly_returnsFalse() { + GenericPatternConfig cfg = new GenericPatternConfig(); + cfg.suffix = "Response"; + // Name IS the suffix (no prefix part) — should not match + assertThat(GenericSchemaScanUtils.matchesPattern("Response", cfg)).isFalse(); + } + + @Test + public void matchesPattern_suffixNoMatch_returnsFalse() { + GenericPatternConfig cfg = new GenericPatternConfig(); + cfg.suffix = "Response"; + assertThat(GenericSchemaScanUtils.matchesPattern("UserResult", cfg)).isFalse(); + assertThat(GenericSchemaScanUtils.matchesPattern("responsePage", cfg)).isFalse(); // case-sensitive + } + + @Test + public void matchesPattern_prefixMatch_returnsTrue() { + GenericPatternConfig cfg = new GenericPatternConfig(); + cfg.prefix = "Api"; + assertThat(GenericSchemaScanUtils.matchesPattern("ApiUser", cfg)).isTrue(); + assertThat(GenericSchemaScanUtils.matchesPattern("ApiPet", cfg)).isTrue(); + } + + @Test + public void matchesPattern_prefixExactNameOnly_returnsFalse() { + GenericPatternConfig cfg = new GenericPatternConfig(); + cfg.prefix = "Api"; + assertThat(GenericSchemaScanUtils.matchesPattern("Api", cfg)).isFalse(); + } + + @Test + public void matchesPattern_noSuffixOrPrefix_returnsFalse() { + GenericPatternConfig cfg = new GenericPatternConfig(); + assertThat(GenericSchemaScanUtils.matchesPattern("AnySchema", cfg)).isFalse(); + } + + // ========================================================================= + // buildStructuralFingerprint + // ========================================================================= + + @Test + public void buildStructuralFingerprint_flatObject_returnsConsistentFingerprint() { + Schema logEntry = entrySchema("LogEntryData"); + Schema metricsEntry = entrySchema("MetricsEntryData"); + + String fp1 = GenericSchemaScanUtils.buildStructuralFingerprint(logEntry); + String fp2 = GenericSchemaScanUtils.buildStructuralFingerprint(metricsEntry); + + assertThat(fp1).isNotNull(); + // Both have same structure (data:$ref, severity:string, timestamp:string) + // so fingerprints should be equal despite different $ref targets + assertThat(fp1).isEqualTo(fp2); + } + + @Test + public void buildStructuralFingerprint_differentStructure_returnsDifferentFingerprints() { + Schema entry = entrySchema("LogEntryData"); + Schema response = responseSchema("User"); + + String fp1 = GenericSchemaScanUtils.buildStructuralFingerprint(entry); + String fp2 = GenericSchemaScanUtils.buildStructuralFingerprint(response); + + assertThat(fp1).isNotNull(); + assertThat(fp2).isNotNull(); + assertThat(fp1).isNotEqualTo(fp2); + } + + @Test + public void buildStructuralFingerprint_allOfSchema_returnsCompositeFingerprint() { + // allOf schemas with ≤1 inline-object entry are now fingerprinted; the result + // encodes the shape ("allOf|"), the sorted extends-bases, and the inline entry's + // property fingerprint so flat/allOf shapes and different bases never collide. + Schema allOf = pageSchemaAllOf("Pet"); + String fp = GenericSchemaScanUtils.buildStructuralFingerprint(allOf); + assertThat(fp).isNotNull(); + assertThat(fp).startsWith("allOf|extends:PageMeta|inline:"); + assertThat(fp).contains("content:array[$ref]"); + } + + @Test + public void buildStructuralFingerprint_allOfWithTwoInlineEntries_returnsNull() { + ComposedSchema two = new ComposedSchema(); + ObjectSchema a = new ObjectSchema(); + Map propsA = new LinkedHashMap<>(); + propsA.put("payload", refSchema("X")); + a.setProperties(propsA); + ObjectSchema b = new ObjectSchema(); + Map propsB = new LinkedHashMap<>(); + propsB.put("status", stringSchema()); + b.setProperties(propsB); + two.setAllOf(Arrays.asList(new Schema<>().$ref(ref("Base")), a, b)); + assertThat(GenericSchemaScanUtils.buildStructuralFingerprint(two)).isNull(); + } + + @Test + public void buildStructuralFingerprint_emptyProperties_returnsNull() { + ObjectSchema empty = new ObjectSchema(); + assertThat(GenericSchemaScanUtils.buildStructuralFingerprint(empty)).isNull(); + } + + // ========================================================================= + // Tier 1 — scanVendorExtensions + // ========================================================================= + + @Test + public void scanVendorExtensions_detectsXGenericClass() { + ObjectSchema vendorSchema = new ObjectSchema(); + Map props = new LinkedHashMap<>(); + props.put("payload", refSchema("User")); + props.put("code", intSchema()); + vendorSchema.setProperties(props); + vendorSchema.setRequired(Collections.singletonList("payload")); + + Map extensions = new LinkedHashMap<>(); + extensions.put("x-generic-class", "com.example.VendorResult"); + Map args = new LinkedHashMap<>(); + args.put("payload", "User"); + extensions.put("x-generic-args", args); + vendorSchema.setExtensions(extensions); + + Map schemas = new LinkedHashMap<>(); + schemas.put("UserVendorResult", vendorSchema); + schemas.put("User", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List result = + GenericSchemaScanUtils.scanVendorExtensions(openAPI); + + assertThat(result).hasSize(1); + GenericSchemaScanUtils.GenericInstance inst = result.get(0); + assertThat(inst.schemaName).isEqualTo("UserVendorResult"); + assertThat(inst.genericClassName).isEqualTo("VendorResult"); + assertThat(inst.genericClassFqn).isEqualTo("com.example.VendorResult"); + assertThat(inst.generateClass).isFalse(); // FQN → Mode A + assertThat(inst.slotProperty).isEqualTo("payload"); + assertThat(inst.slotIsArray).isFalse(); + assertThat(inst.firstTypeArg()).isEqualTo("User"); + } + + @Test + public void scanVendorExtensions_simpleNameGenericClass_isModeB() { + ObjectSchema schema = new ObjectSchema(); + Map props = new LinkedHashMap<>(); + props.put("data", refSchema("Pet")); + schema.setProperties(props); + + Map extensions = new LinkedHashMap<>(); + extensions.put("x-generic-class", "MyGeneric"); + Map args = new LinkedHashMap<>(); + args.put("data", "Pet"); + extensions.put("x-generic-args", args); + schema.setExtensions(extensions); + + Map schemas = new LinkedHashMap<>(); + schemas.put("PetMyGeneric", schema); + schemas.put("Pet", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List result = + GenericSchemaScanUtils.scanVendorExtensions(openAPI); + + assertThat(result).hasSize(1); + GenericSchemaScanUtils.GenericInstance inst = result.get(0); + assertThat(inst.genericClassName).isEqualTo("MyGeneric"); + assertThat(inst.genericClassFqn).isNull(); + assertThat(inst.generateClass).isTrue(); // no dot → Mode B + } + + @Test + public void scanVendorExtensions_missingXGenericArgs_skipsSchema() { + ObjectSchema schema = new ObjectSchema(); + Map extensions = new LinkedHashMap<>(); + extensions.put("x-generic-class", "com.example.Whatever"); + // no x-generic-args + schema.setExtensions(extensions); + + Map schemas = new LinkedHashMap<>(); + schemas.put("NoArgsSchema", schema); + OpenAPI openAPI = buildOpenAPI(schemas); + + List result = + GenericSchemaScanUtils.scanVendorExtensions(openAPI); + + assertThat(result).isEmpty(); + } + + @Test + public void scanVendorExtensions_noExtensions_returnsEmpty() { + Map schemas = new LinkedHashMap<>(); + schemas.put("PlainSchema", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + assertThat(GenericSchemaScanUtils.scanVendorExtensions(openAPI)).isEmpty(); + } + + @Test + public void scanVendorExtensions_emptyOpenAPI_returnsEmpty() { + assertThat(GenericSchemaScanUtils.scanVendorExtensions(new OpenAPI())).isEmpty(); + } + + // ========================================================================= + // Tier 2 — scanWithPatterns (slot / $ref) + // ========================================================================= + + @Test + public void scanWithPatterns_suffixSlot_matchesAllSuffix() { + Map schemas = new LinkedHashMap<>(); + schemas.put("UserResponse", responseSchema("User")); + schemas.put("PetResponse", responseSchema("Pet")); + schemas.put("OrderResponse", responseSchema("Order")); + schemas.put("User", new ObjectSchema()); + schemas.put("Pet", new ObjectSchema()); + schemas.put("Order", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List patterns = Collections.singletonList( + suffixSlotPattern("Response", "ApiResponse", "data")); + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, patterns, Collections.emptySet()); + + assertThat(result).hasSize(3); + + List matched = new ArrayList<>(); + for (GenericSchemaScanUtils.GenericInstance inst : result) { + matched.add(inst.schemaName); + assertThat(inst.genericClassName).isEqualTo("ApiResponse"); + assertThat(inst.slotProperty).isEqualTo("data"); + assertThat(inst.slotIsArray).isFalse(); + } + assertThat(matched).containsExactlyInAnyOrder("UserResponse", "PetResponse", "OrderResponse"); + } + + @Test + public void scanWithPatterns_fqnGenericClass_isModeA() { + Map schemas = new LinkedHashMap<>(); + schemas.put("UserResponse", responseSchema("User")); + schemas.put("User", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List patterns = Collections.singletonList( + suffixSlotPattern("Response", "com.example.ApiResponse", "data")); + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, patterns, Collections.emptySet()); + + assertThat(result).hasSize(1); + GenericSchemaScanUtils.GenericInstance inst = result.get(0); + assertThat(inst.genericClassFqn).isEqualTo("com.example.ApiResponse"); + assertThat(inst.generateClass).isFalse(); + } + + @Test + public void scanWithPatterns_simpleNameGenericClass_isModeB() { + Map schemas = new LinkedHashMap<>(); + schemas.put("UserResponse", responseSchema("User")); + schemas.put("User", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List patterns = Collections.singletonList( + suffixSlotPattern("Response", "ApiResponse", "data")); + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, patterns, Collections.emptySet()); + + assertThat(result).hasSize(1); + assertThat(result.get(0).genericClassFqn).isNull(); + assertThat(result.get(0).generateClass).isTrue(); + } + + @Test + public void scanWithPatterns_schemaNameExactlySuffix_doesNotMatch() { + Map schemas = new LinkedHashMap<>(); + schemas.put("Response", responseSchema("User")); // name == suffix, should not match + schemas.put("User", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List patterns = Collections.singletonList( + suffixSlotPattern("Response", "ApiResponse", "data")); + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, patterns, Collections.emptySet()); + + assertThat(result).isEmpty(); + } + + @Test + public void scanWithPatterns_slotPropertyAbsent_doesNotMatch() { + // Schema has no "data" property + ObjectSchema noDataSchema = new ObjectSchema(); + Map props = new LinkedHashMap<>(); + props.put("payload", refSchema("User")); // wrong property name + props.put("status", stringSchema()); + noDataSchema.setProperties(props); + + Map schemas = new LinkedHashMap<>(); + schemas.put("UserResponse", noDataSchema); + schemas.put("User", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List patterns = Collections.singletonList( + suffixSlotPattern("Response", "ApiResponse", "data")); // expects "data" + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, patterns, Collections.emptySet()); + + assertThat(result).isEmpty(); + } + + @Test + public void scanWithPatterns_tier1ExcludedSchemas_areSkipped() { + Map schemas = new LinkedHashMap<>(); + schemas.put("UserResponse", responseSchema("User")); + schemas.put("PetResponse", responseSchema("Pet")); + schemas.put("User", new ObjectSchema()); + schemas.put("Pet", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List patterns = Collections.singletonList( + suffixSlotPattern("Response", "ApiResponse", "data")); + + // Exclude UserResponse (already handled by Tier 1) + Set tier1 = Collections.singleton("UserResponse"); + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, patterns, tier1); + + assertThat(result).hasSize(1); + assertThat(result.get(0).schemaName).isEqualTo("PetResponse"); + } + + @Test + public void scanWithPatterns_firstMatchingPatternWins() { + Map schemas = new LinkedHashMap<>(); + schemas.put("UserResponse", responseSchema("User")); + schemas.put("User", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List patterns = Arrays.asList( + suffixSlotPattern("Response", "FirstApiResponse", "data"), + suffixSlotPattern("Response", "SecondApiResponse", "data") + ); + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, patterns, Collections.emptySet()); + + assertThat(result).hasSize(1); + assertThat(result.get(0).genericClassName).isEqualTo("FirstApiResponse"); + } + + // ========================================================================= + // Tier 2 — scanWithPatterns (slotArray) + // ========================================================================= + + @Test + public void scanWithPatterns_slotArrayFlatForm_matchesArraySlot() { + Map schemas = new LinkedHashMap<>(); + schemas.put("UserPage", pageSchemaFlat("User")); + schemas.put("PageMeta", new ObjectSchema()); + schemas.put("User", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List patterns = Collections.singletonList( + suffixSlotArrayPattern("Page", "org.springframework.data.domain.Page", "content")); + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, patterns, Collections.emptySet()); + + assertThat(result).hasSize(1); + GenericSchemaScanUtils.GenericInstance inst = result.get(0); + assertThat(inst.schemaName).isEqualTo("UserPage"); + assertThat(inst.slotProperty).isEqualTo("content"); + assertThat(inst.slotIsArray).isTrue(); + assertThat(inst.firstTypeArg()).isEqualTo("User"); + } + + @Test + public void scanWithPatterns_slotArrayAllOfForm_matchesArraySlot() { + Map schemas = new LinkedHashMap<>(); + schemas.put("PetPage", pageSchemaAllOf("Pet")); + schemas.put("PageMeta", new ObjectSchema()); + schemas.put("Pet", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List patterns = Collections.singletonList( + suffixSlotArrayPattern("Page", "org.springframework.data.domain.Page", "content")); + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, patterns, Collections.emptySet()); + + assertThat(result).hasSize(1); + GenericSchemaScanUtils.GenericInstance inst = result.get(0); + assertThat(inst.schemaName).isEqualTo("PetPage"); + assertThat(inst.slotProperty).isEqualTo("content"); + assertThat(inst.slotIsArray).isTrue(); + assertThat(inst.firstTypeArg()).isEqualTo("Pet"); + } + + @Test + public void scanWithPatterns_slotArrayMissingContent_doesNotMatch() { + // Schema has "items" array but not "content" + ObjectSchema wrongSlot = new ObjectSchema(); + Map props = new LinkedHashMap<>(); + props.put("items", arrayRefSchema("User")); // wrong slot name + props.put("page", refSchema("PageMeta")); + wrongSlot.setProperties(props); + + Map schemas = new LinkedHashMap<>(); + schemas.put("UserPage", wrongSlot); + schemas.put("PageMeta", new ObjectSchema()); + schemas.put("User", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List patterns = Collections.singletonList( + suffixSlotArrayPattern("Page", "org.springframework.data.domain.Page", "content")); + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, patterns, Collections.emptySet()); + + assertThat(result).isEmpty(); + } + + @Test + public void scanWithPatterns_emptyPatternList_returnsEmpty() { + Map schemas = new LinkedHashMap<>(); + schemas.put("UserResponse", responseSchema("User")); + schemas.put("User", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, Collections.emptyList(), Collections.emptySet()); + + assertThat(result).isEmpty(); + } + + @Test + public void scanWithPatterns_patternWithNoGenericClass_isSkipped() { + Map schemas = new LinkedHashMap<>(); + schemas.put("UserResponse", responseSchema("User")); + schemas.put("User", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + GenericPatternConfig bad = new GenericPatternConfig(); + bad.suffix = "Response"; + // genericClass intentionally omitted + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, Collections.singletonList(bad), Collections.emptySet()); + + assertThat(result).isEmpty(); + } + + // ========================================================================= + // Tier 2 — property metadata in GenericInstance + // ========================================================================= + + @Test + public void scanWithPatterns_properties_containsSlotWithTypeParam() { + Map schemas = new LinkedHashMap<>(); + schemas.put("UserResponse", responseSchema("User")); + schemas.put("User", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List patterns = Collections.singletonList( + suffixSlotPattern("Response", "ApiResponse", "data")); + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, patterns, Collections.emptySet()); + + assertThat(result).hasSize(1); + List props = result.get(0).properties; + + GenericSchemaScanUtils.GenericProperty dataSlot = props.stream() + .filter(p -> "data".equals(p.name)).findFirst().orElse(null); + assertThat(dataSlot).isNotNull(); + assertThat(dataSlot.typeParam).isEqualTo("T"); + assertThat(dataSlot.isArray).isFalse(); + + GenericSchemaScanUtils.GenericProperty status = props.stream() + .filter(p -> "status".equals(p.name)).findFirst().orElse(null); + assertThat(status).isNotNull(); + assertThat(status.typeParam).isNull(); + assertThat(status.openApiType).isEqualTo("string"); + } + + @Test + public void scanWithPatterns_arraySlotProperty_hasIsArrayTrue() { + Map schemas = new LinkedHashMap<>(); + schemas.put("UserPage", pageSchemaFlat("User")); + schemas.put("PageMeta", new ObjectSchema()); + schemas.put("User", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List patterns = Collections.singletonList( + suffixSlotArrayPattern("Page", "Page", "content")); + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, patterns, Collections.emptySet()); + + assertThat(result).hasSize(1); + GenericSchemaScanUtils.GenericProperty contentSlot = result.get(0).properties.stream() + .filter(p -> "content".equals(p.name)).findFirst().orElse(null); + assertThat(contentSlot).isNotNull(); + assertThat(contentSlot.typeParam).isEqualTo("T"); + assertThat(contentSlot.isArray).isTrue(); + } + + // ========================================================================= + // Tier 3 — discoverClusters + // ========================================================================= + + @Test + public void discoverClusters_twoStructurallySimilarSchemas_returnsCluster() { + Map schemas = new LinkedHashMap<>(); + schemas.put("LogEntry", entrySchema("LogEntryData")); + schemas.put("MetricsEntry", entrySchema("MetricsEntryData")); + schemas.put("LogEntryData", new ObjectSchema()); + schemas.put("MetricsEntryData", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List suggestions = + GenericSchemaScanUtils.discoverClusters(openAPI, Collections.emptySet()); + + assertThat(suggestions).hasSize(1); + GenericSchemaScanUtils.ClusterSuggestion suggestion = suggestions.get(0); + assertThat(suggestion.schemaNames).containsExactlyInAnyOrder("LogEntry", "MetricsEntry"); + assertThat(suggestion.varyingSlotProperty).isEqualTo("data"); + assertThat(suggestion.varyingTypes).containsExactlyInAnyOrder("LogEntryData", "MetricsEntryData"); + assertThat(suggestion.suggestedConfig).isNotBlank(); + } + + @Test + public void discoverClusters_threeStructurallySimilarSchemas_returnsOneCluster() { + Map schemas = new LinkedHashMap<>(); + schemas.put("UserResponse", responseSchema("User")); + schemas.put("PetResponse", responseSchema("Pet")); + schemas.put("OrderResponse", responseSchema("Order")); + schemas.put("User", new ObjectSchema()); + schemas.put("Pet", new ObjectSchema()); + schemas.put("Order", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List suggestions = + GenericSchemaScanUtils.discoverClusters(openAPI, Collections.emptySet()); + + assertThat(suggestions).hasSize(1); + GenericSchemaScanUtils.ClusterSuggestion s = suggestions.get(0); + assertThat(s.schemaNames).containsExactlyInAnyOrder("UserResponse", "PetResponse", "OrderResponse"); + assertThat(s.varyingSlotProperty).isEqualTo("data"); + assertThat(s.varyingTypes).containsExactlyInAnyOrder("User", "Pet", "Order"); + } + + @Test + public void discoverClusters_uniqueStructure_returnsEmpty() { + // SearchResult has a unique structure — no cluster + ObjectSchema searchResult = new ObjectSchema(); + Map props = new LinkedHashMap<>(); + props.put("query", stringSchema()); + props.put("totalHits", intSchema()); + props.put("results", new ArraySchema().items(stringSchema())); + searchResult.setProperties(props); + + Map schemas = new LinkedHashMap<>(); + schemas.put("SearchResult", searchResult); + OpenAPI openAPI = buildOpenAPI(schemas); + + List suggestions = + GenericSchemaScanUtils.discoverClusters(openAPI, Collections.emptySet()); + + assertThat(suggestions).isEmpty(); + } + + @Test + public void discoverClusters_excludedSchemas_notIncludedInClusters() { + Map schemas = new LinkedHashMap<>(); + schemas.put("LogEntry", entrySchema("LogEntryData")); + schemas.put("MetricsEntry", entrySchema("MetricsEntryData")); + schemas.put("LogEntryData", new ObjectSchema()); + schemas.put("MetricsEntryData", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + // Exclude both — cluster should not form + Set excluded = new HashSet<>(Arrays.asList("LogEntry", "MetricsEntry")); + List suggestions = + GenericSchemaScanUtils.discoverClusters(openAPI, excluded); + + assertThat(suggestions).isEmpty(); + } + + @Test + public void discoverClusters_allOfPagedSchemas_returnsArraySlotCluster() { + // Two paged schemas using allOf: shared extends-base PageMeta, single inline entry + // with a `content: array[$ref]` slot. Discovery should cluster these into a + // slotArray suggestion (overlaps semantically with substituteGenericPagedModel, + // but that's fine — discovery is log-only). + Map schemas = new LinkedHashMap<>(); + schemas.put("PetPage", pageSchemaAllOf("Pet")); + schemas.put("UserPage", pageSchemaAllOf("User")); + schemas.put("Pet", new ObjectSchema()); + schemas.put("User", new ObjectSchema()); + schemas.put("PageMeta", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List suggestions = + GenericSchemaScanUtils.discoverClusters(openAPI, Collections.emptySet()); + + assertThat(suggestions).hasSize(1); + GenericSchemaScanUtils.ClusterSuggestion s = suggestions.get(0); + assertThat(s.schemaNames).containsExactlyInAnyOrder("PetPage", "UserPage"); + assertThat(s.varyingSlotProperty).isEqualTo("content"); + assertThat(s.isArraySlot).isTrue(); + assertThat(s.varyingTypes).containsExactlyInAnyOrder("Pet", "User"); + assertThat(s.suggestedConfig).contains("slotArray: content"); + } + + @Test + public void discoverClusters_allOfEventStyleSchemas_returnsCluster() { + // Event-envelope shape: allOf [$ref BaseEvent, {payload: $ref ...}] + // This is the canonical case that substituteGenericPagedModel does NOT cover. + Map schemas = new LinkedHashMap<>(); + schemas.put("UserEvent", eventSchemaAllOf("User")); + schemas.put("OrderEvent", eventSchemaAllOf("Order")); + schemas.put("User", new ObjectSchema()); + schemas.put("Order", new ObjectSchema()); + schemas.put("BaseEvent", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List suggestions = + GenericSchemaScanUtils.discoverClusters(openAPI, Collections.emptySet()); + + assertThat(suggestions).hasSize(1); + GenericSchemaScanUtils.ClusterSuggestion s = suggestions.get(0); + assertThat(s.schemaNames).containsExactlyInAnyOrder("UserEvent", "OrderEvent"); + assertThat(s.varyingSlotProperty).isEqualTo("payload"); + assertThat(s.isArraySlot).isFalse(); + assertThat(s.varyingTypes).containsExactlyInAnyOrder("User", "Order"); + assertThat(s.suggestedConfig).contains("slot: payload"); + assertThat(s.suggestedConfig).doesNotContain("slotArray:"); + } + + @Test + public void discoverClusters_allOfDifferentExtends_doesNotCluster() { + // Same inline shape but DIFFERENT extends-bases — must NOT cluster, because the + // base schemas are part of the structural identity (PageMeta vs CursorMeta). + Schema pageMetaBased = pageSchemaAllOf("User"); // extends PageMeta + + ComposedSchema cursorBased = new ComposedSchema(); + ObjectSchema cursorInline = new ObjectSchema(); + Map cursorProps = new LinkedHashMap<>(); + cursorProps.put("content", arrayRefSchema("Order")); + cursorInline.setProperties(cursorProps); + cursorBased.setAllOf(Arrays.asList(new Schema<>().$ref(ref("CursorMeta")), cursorInline)); + + Map schemas = new LinkedHashMap<>(); + schemas.put("UserPage", pageMetaBased); + schemas.put("OrderPage", cursorBased); + schemas.put("User", new ObjectSchema()); + schemas.put("Order", new ObjectSchema()); + schemas.put("PageMeta", new ObjectSchema()); + schemas.put("CursorMeta", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List suggestions = + GenericSchemaScanUtils.discoverClusters(openAPI, Collections.emptySet()); + + assertThat(suggestions).isEmpty(); + } + + @Test + public void discoverClusters_allOfMixedFlatAndAllOf_doesNotCluster() { + // A flat-object schema and an allOf schema with the same merged property set must + // NOT cluster — the fingerprint includes a shape marker (flat| vs allOf|) so the + // two stay in distinct buckets. + Map schemas = new LinkedHashMap<>(); + schemas.put("UserPage", pageSchemaFlat("User")); // flat: content + page + schemas.put("PetPage", pageSchemaAllOf("Pet")); // allOf: extends PageMeta + content + schemas.put("User", new ObjectSchema()); + schemas.put("Pet", new ObjectSchema()); + schemas.put("PageMeta", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List suggestions = + GenericSchemaScanUtils.discoverClusters(openAPI, Collections.emptySet()); + + assertThat(suggestions).isEmpty(); + } + + @Test + public void discoverClusters_allOfWithTwoInlineEntries_skipped() { + // Out-of-scope edge case: allOf with TWO inline-object entries is ambiguous + // (which entry owns the slot?). Such schemas must be skipped entirely. + ComposedSchema two = new ComposedSchema(); + ObjectSchema inlineA = new ObjectSchema(); + Map propsA = new LinkedHashMap<>(); + propsA.put("payload", refSchema("User")); + inlineA.setProperties(propsA); + ObjectSchema inlineB = new ObjectSchema(); + Map propsB = new LinkedHashMap<>(); + propsB.put("status", stringSchema()); + inlineB.setProperties(propsB); + two.setAllOf(Arrays.asList(new Schema<>().$ref(ref("BaseEvent")), inlineA, inlineB)); + + ComposedSchema two2 = new ComposedSchema(); + ObjectSchema inlineA2 = new ObjectSchema(); + Map propsA2 = new LinkedHashMap<>(); + propsA2.put("payload", refSchema("Order")); + inlineA2.setProperties(propsA2); + ObjectSchema inlineB2 = new ObjectSchema(); + Map propsB2 = new LinkedHashMap<>(); + propsB2.put("status", stringSchema()); + inlineB2.setProperties(propsB2); + two2.setAllOf(Arrays.asList(new Schema<>().$ref(ref("BaseEvent")), inlineA2, inlineB2)); + + Map schemas = new LinkedHashMap<>(); + schemas.put("UserEvent", two); + schemas.put("OrderEvent", two2); + schemas.put("User", new ObjectSchema()); + schemas.put("Order", new ObjectSchema()); + schemas.put("BaseEvent", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List suggestions = + GenericSchemaScanUtils.discoverClusters(openAPI, Collections.emptySet()); + + assertThat(suggestions).isEmpty(); + } + + @Test + public void discoverClusters_suggestedConfigContainsSlotAndSuffix() { + Map schemas = new LinkedHashMap<>(); + schemas.put("LogEntry", entrySchema("LogEntryData")); + schemas.put("MetricsEntry", entrySchema("MetricsEntryData")); + schemas.put("LogEntryData", new ObjectSchema()); + schemas.put("MetricsEntryData", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List suggestions = + GenericSchemaScanUtils.discoverClusters(openAPI, Collections.emptySet()); + + assertThat(suggestions).hasSize(1); + String config = suggestions.get(0).suggestedConfig; + // Config should mention slot name and a suffix or prefix suggestion + assertThat(config).contains("data"); // slot property + } + + @Test + public void discoverClusters_singleSchema_doesNotFormCluster() { + Map schemas = new LinkedHashMap<>(); + schemas.put("OnlyEntry", entrySchema("SomeData")); + schemas.put("SomeData", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List suggestions = + GenericSchemaScanUtils.discoverClusters(openAPI, Collections.emptySet()); + + // Cluster requires >= 2 members + assertThat(suggestions).isEmpty(); + } + + @Test + public void discoverClusters_flatPagedSchemas_returnsArraySlotCluster() { + // Two paged schemas with the same shape ({ content: array[$ref], page: $ref PageMeta }). + // Only `content` varies (different array item refs); `page` is identical across both. + Map schemas = new LinkedHashMap<>(); + schemas.put("UserPage", pageSchemaFlat("User")); + schemas.put("PetPage", pageSchemaFlat("Pet")); + schemas.put("User", new ObjectSchema()); + schemas.put("Pet", new ObjectSchema()); + schemas.put("PageMeta", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List suggestions = + GenericSchemaScanUtils.discoverClusters(openAPI, Collections.emptySet()); + + assertThat(suggestions).hasSize(1); + GenericSchemaScanUtils.ClusterSuggestion s = suggestions.get(0); + assertThat(s.schemaNames).containsExactlyInAnyOrder("UserPage", "PetPage"); + assertThat(s.varyingSlotProperty).isEqualTo("content"); + assertThat(s.isArraySlot).isTrue(); + assertThat(s.varyingTypes).containsExactlyInAnyOrder("User", "Pet"); + // Suggested config must reference slotArray, not slot, so the user gets a working pattern. + assertThat(s.suggestedConfig).contains("slotArray: content"); + assertThat(s.suggestedConfig).doesNotContain("slot: content"); + } + + @Test + public void discoverClusters_pagedSchemasWithVaryingMetadata_doesNotCluster() { + // Sanity check: if BOTH the array-of-$ref AND the metadata $ref vary across members, + // findVaryingRefProperty must reject (more than one varying property). + ObjectSchema a = new ObjectSchema(); + Map propsA = new LinkedHashMap<>(); + propsA.put("content", arrayRefSchema("User")); + propsA.put("page", refSchema("PageMetaA")); + a.setProperties(propsA); + + ObjectSchema b = new ObjectSchema(); + Map propsB = new LinkedHashMap<>(); + propsB.put("content", arrayRefSchema("Pet")); + propsB.put("page", refSchema("PageMetaB")); + b.setProperties(propsB); + + Map schemas = new LinkedHashMap<>(); + schemas.put("UserPage", a); + schemas.put("PetPage", b); + schemas.put("User", new ObjectSchema()); + schemas.put("Pet", new ObjectSchema()); + schemas.put("PageMetaA", new ObjectSchema()); + schemas.put("PageMetaB", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List suggestions = + GenericSchemaScanUtils.discoverClusters(openAPI, Collections.emptySet()); + + assertThat(suggestions).isEmpty(); + } + + // ========================================================================= + // resolveProperties — allOf merging + // ========================================================================= + + @Test + public void resolveProperties_allOfSchema_mergesPropertiesFromInlineEntries() { + Schema allOf = pageSchemaAllOf("Pet"); + OpenAPI openAPI = buildOpenAPI(Collections.singletonMap("Pet", new ObjectSchema())); + + Map props = GenericSchemaScanUtils.resolveProperties(allOf, openAPI); + + assertThat(props).isNotNull(); + assertThat(props).containsKey("content"); + } + + @Test + public void resolveProperties_flatSchema_returnsSchemaProperties() { + Schema flat = responseSchema("User"); + OpenAPI openAPI = buildOpenAPI(Collections.singletonMap("User", new ObjectSchema())); + + Map props = GenericSchemaScanUtils.resolveProperties(flat, openAPI); + + assertThat(props).isNotNull(); + assertThat(props).containsKeys("data", "status", "message"); + } + + @Test + public void resolveProperties_emptySchema_returnsNull() { + assertThat(GenericSchemaScanUtils.resolveProperties(new ObjectSchema(), new OpenAPI())).isNull(); + } + + // ========================================================================= + // Multi-type-parameter — scanWithPatterns (slots) + // ========================================================================= + + @Test + public void scanWithPatterns_multiSlot_resolvesBothTypeArgs() { + Map schemas = new LinkedHashMap<>(); + schemas.put("UserErrorResult", resultSchema("User", "ValidationError")); + schemas.put("OrderErrorResult", resultSchema("Order", "PaymentError")); + schemas.put("User", new ObjectSchema()); + schemas.put("Order", new ObjectSchema()); + schemas.put("ValidationError", new ObjectSchema()); + schemas.put("PaymentError", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + Map slots = new LinkedHashMap<>(); + slots.put("data", "T"); + slots.put("error", "E"); + GenericPatternConfig cfg = new GenericPatternConfig() + .suffix("ErrorResult").genericClass("Result").slots(slots); + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, Collections.singletonList(cfg), + Collections.emptySet()); + + assertThat(result).hasSize(2); + + GenericSchemaScanUtils.GenericInstance user = result.stream() + .filter(i -> "UserErrorResult".equals(i.schemaName)).findFirst().orElse(null); + assertThat(user).isNotNull(); + assertThat(user.genericClassName).isEqualTo("Result"); + assertThat(user.typeArgs).containsEntry("data", "User"); + assertThat(user.typeArgs).containsEntry("error", "ValidationError"); + assertThat(user.slotTypeParams).containsEntry("data", "T"); + assertThat(user.slotTypeParams).containsEntry("error", "E"); + assertThat(user.slotProperty).isEqualTo("data"); + assertThat(user.slotIsArray).isFalse(); + + GenericSchemaScanUtils.GenericInstance order = result.stream() + .filter(i -> "OrderErrorResult".equals(i.schemaName)).findFirst().orElse(null); + assertThat(order).isNotNull(); + assertThat(order.typeArgs).containsEntry("data", "Order"); + assertThat(order.typeArgs).containsEntry("error", "PaymentError"); + } + + @Test + public void scanWithPatterns_multiSlot_propertiesHaveCorrectTypeParams() { + Map schemas = new LinkedHashMap<>(); + schemas.put("UserErrorResult", resultSchema("User", "ValidationError")); + schemas.put("User", new ObjectSchema()); + schemas.put("ValidationError", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + Map slots = new LinkedHashMap<>(); + slots.put("data", "T"); + slots.put("error", "E"); + GenericPatternConfig cfg = new GenericPatternConfig() + .suffix("ErrorResult").genericClass("Result").slots(slots); + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, Collections.singletonList(cfg), + Collections.emptySet()); + + assertThat(result).hasSize(1); + List props = result.get(0).properties; + + GenericSchemaScanUtils.GenericProperty dataProp = props.stream() + .filter(p -> "data".equals(p.name)).findFirst().orElse(null); + assertThat(dataProp).isNotNull(); + assertThat(dataProp.typeParam).isEqualTo("T"); + assertThat(dataProp.isArray).isFalse(); + + GenericSchemaScanUtils.GenericProperty errorProp = props.stream() + .filter(p -> "error".equals(p.name)).findFirst().orElse(null); + assertThat(errorProp).isNotNull(); + assertThat(errorProp.typeParam).isEqualTo("E"); + assertThat(errorProp.isArray).isFalse(); + + // Non-slot property has no typeParam + GenericSchemaScanUtils.GenericProperty successProp = props.stream() + .filter(p -> "success".equals(p.name)).findFirst().orElse(null); + assertThat(successProp).isNotNull(); + assertThat(successProp.typeParam).isNull(); + } + + @Test + public void scanWithPatterns_multiSlot_partialSlotMissing_doesNotMatch() { + // Schema has 'data' but not 'error' — should NOT match + ObjectSchema schema = new ObjectSchema(); + Map props = new LinkedHashMap<>(); + props.put("data", refSchema("User")); + props.put("success", new BooleanSchema()); + schema.setProperties(props); + + Map schemas = new LinkedHashMap<>(); + schemas.put("UserErrorResult", schema); + schemas.put("User", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + Map slots = new LinkedHashMap<>(); + slots.put("data", "T"); + slots.put("error", "E"); // 'error' absent in schema + GenericPatternConfig cfg = new GenericPatternConfig() + .suffix("ErrorResult").genericClass("Result").slots(slots); + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, Collections.singletonList(cfg), + Collections.emptySet()); + + assertThat(result).isEmpty(); + } + + @Test + public void scanWithPatterns_slotsFieldTakesPrecedenceOverSlot() { + Map schemas = new LinkedHashMap<>(); + schemas.put("UserErrorResult", resultSchema("User", "ValidationError")); + schemas.put("User", new ObjectSchema()); + schemas.put("ValidationError", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + // Both slots and slot set — slots should win + Map slots = new LinkedHashMap<>(); + slots.put("data", "T"); + slots.put("error", "E"); + GenericPatternConfig cfg = new GenericPatternConfig() + .suffix("ErrorResult").genericClass("Result") + .slot("payload") // this should be ignored because slots is set + .slots(slots); + + List result = + GenericSchemaScanUtils.scanWithPatterns(openAPI, Collections.singletonList(cfg), + Collections.emptySet()); + + // slots wins: data+error found → match + assertThat(result).hasSize(1); + assertThat(result.get(0).typeArgs).containsKey("data"); + assertThat(result.get(0).typeArgs).containsKey("error"); + } + + // ========================================================================= + // Multi-type-parameter — scanVendorExtensions + // ========================================================================= + + @Test + public void scanVendorExtensions_multiSlotArgs_assignsLettersByPosition() { + ObjectSchema schema = new ObjectSchema(); + Map props = new LinkedHashMap<>(); + props.put("data", refSchema("User")); + props.put("error", refSchema("ValidationError")); + schema.setProperties(props); + + Map extensions = new LinkedHashMap<>(); + extensions.put("x-generic-class", "Result"); + Map args = new LinkedHashMap<>(); + args.put("data", "User"); + args.put("error", "ValidationError"); + extensions.put("x-generic-args", args); + schema.setExtensions(extensions); + + Map schemas = new LinkedHashMap<>(); + schemas.put("UserErrorResult", schema); + schemas.put("User", new ObjectSchema()); + schemas.put("ValidationError", new ObjectSchema()); + OpenAPI openAPI = buildOpenAPI(schemas); + + List result = + GenericSchemaScanUtils.scanVendorExtensions(openAPI); + + assertThat(result).hasSize(1); + GenericSchemaScanUtils.GenericInstance inst = result.get(0); + assertThat(inst.typeArgs).containsEntry("data", "User"); + assertThat(inst.typeArgs).containsEntry("error", "ValidationError"); + // First slot → T, second slot → E + assertThat(inst.slotTypeParams).containsEntry("data", "T"); + assertThat(inst.slotTypeParams).containsEntry("error", "E"); + } +} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/PagedModelScanUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/PagedModelScanUtilsTest.java index ef39ad954208..3aedae62e724 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/PagedModelScanUtilsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/PagedModelScanUtilsTest.java @@ -353,4 +353,57 @@ public void extractSchemaNameFromRef_returnsRefAsIsWhenNoSlash() { public void extractSchemaNameFromRef_returnsNullForNull() { assertThat(PagedModelScanUtils.extractSchemaNameFromRef(null)).isNull(); } + + // ------------------------------------------------------------------------- + // scanPagedModels(OpenAPI, UnaryOperator) — transform overload + // ------------------------------------------------------------------------- + + @Test + public void scanPagedModels_withTransform_appliesTransformToKeySchemaNameAndMetaSchemaName() { + // Build a minimal paged schema so the scan detects one entry. + ArraySchema contentSchema = new ArraySchema(); + contentSchema.setItems(new Schema<>().$ref(ref("User"))); + + ObjectSchema userPageSchema = new ObjectSchema(); + Map props = new LinkedHashMap<>(); + props.put("content", contentSchema); + props.put("page", new Schema<>().$ref(ref("PageMetadata"))); + userPageSchema.setProperties(props); + + Map schemas = new LinkedHashMap<>(); + schemas.put("PageMetadata", pageMetadataSchema()); + schemas.put("User", new ObjectSchema()); + schemas.put("UserPage", userPageSchema); + + OpenAPI openAPI = buildOpenAPI(schemas); + + // Simulate a generator that appends "Dto" to every model name. + Map result = + PagedModelScanUtils.scanPagedModels(openAPI, name -> name + "Dto"); + + // Key, schemaName, and metaSchemaName must all have the suffix applied. + assertThat(result).containsKey("UserPageDto"); + assertThat(result).doesNotContainKey("UserPage"); + + PagedModelScanUtils.DetectedPagedModel detected = result.get("UserPageDto"); + assertThat(detected.schemaName).isEqualTo("UserPageDto"); + assertThat(detected.metaSchemaName).isEqualTo("PageMetadataDto"); + // itemSchemaName is intentionally left raw (transform is applied at call site). + assertThat(detected.itemSchemaName).isEqualTo("User"); + // Raw names must be preserved for objs.remove() in postProcessAllModels. + assertThat(detected.rawSchemaName).isEqualTo("UserPage"); + assertThat(detected.rawMetaSchemaName).isEqualTo("PageMetadata"); + } + + @Test + public void scanPagedModels_withTransform_returnsEmptyWhenNoSchemasDetected() { + OpenAPI openAPI = new OpenAPI(); + openAPI.setComponents(new Components()); + + Map result = + PagedModelScanUtils.scanPagedModels(openAPI, name -> name + "Dto"); + + assertThat(result).isEmpty(); + } } + diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java new file mode 100644 index 000000000000..e42586035c83 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java @@ -0,0 +1,602 @@ +package org.openapitools.codegen.languages; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.IntegerSchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.Parameter; +import org.openapitools.codegen.CodegenOperation; +import org.testng.annotations.Test; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Unit tests for {@link SpringPageableScanUtils}. + */ +public class SpringPageableScanUtilsTest { + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Builds an OpenAPI doc with a single GET /items operation marked x-spring-paginated, + * accepting an arbitrary list of parameters. + */ + private static OpenAPI buildPageableOperationWithParams(List params) { + Operation op = new Operation(); + op.setOperationId("listItems"); + op.addExtension("x-spring-paginated", true); + params.forEach(op::addParametersItem); + + PathItem pathItem = new PathItem(); + pathItem.setGet(op); + + Paths paths = new Paths(); + paths.addPathItem("/items", pathItem); + + OpenAPI openAPI = new OpenAPI(); + openAPI.setPaths(paths); + return openAPI; + } + + // ------------------------------------------------------------------------- + // scanPageableConstraints — inclusive bounds (baseline) + // ------------------------------------------------------------------------- + + @Test + public void scanPageableConstraints_inclusiveBounds_usedDirectly() { + Schema pageSchema = new IntegerSchema(); + pageSchema.setMaximum(BigDecimal.valueOf(100)); + pageSchema.setMinimum(BigDecimal.valueOf(0)); + + Schema sizeSchema = new IntegerSchema(); + sizeSchema.setMaximum(BigDecimal.valueOf(50)); + sizeSchema.setMinimum(BigDecimal.valueOf(1)); + + OpenAPI openAPI = buildPageableOperationWithParams(List.of( + new Parameter().name("page").schema(pageSchema), + new Parameter().name("size").schema(sizeSchema) + )); + + Map result = + SpringPageableScanUtils.scanPageableConstraints(openAPI, false); + + assertThat(result).containsKey("listItems"); + SpringPageableScanUtils.PageableConstraintsData data = result.get("listItems"); + assertThat(data.maxPage).isEqualTo(100); + assertThat(data.minPage).isEqualTo(0); + assertThat(data.maxSize).isEqualTo(50); + assertThat(data.minSize).isEqualTo(1); + } + + // ------------------------------------------------------------------------- + // scanPageableConstraints — exclusive bounds + // ------------------------------------------------------------------------- + + @Test + public void scanPageableConstraints_exclusiveMaximum_subtractsOne() { + Schema pageSchema = new IntegerSchema(); + pageSchema.setMaximum(BigDecimal.valueOf(101)); + pageSchema.setExclusiveMaximum(Boolean.TRUE); // exclusive 101 → effective max = 100 + + Schema sizeSchema = new IntegerSchema(); + sizeSchema.setMaximum(BigDecimal.valueOf(51)); + sizeSchema.setExclusiveMaximum(Boolean.TRUE); // exclusive 51 → effective max = 50 + + OpenAPI openAPI = buildPageableOperationWithParams(List.of( + new Parameter().name("page").schema(pageSchema), + new Parameter().name("size").schema(sizeSchema) + )); + + Map result = + SpringPageableScanUtils.scanPageableConstraints(openAPI, false); + + assertThat(result).containsKey("listItems"); + SpringPageableScanUtils.PageableConstraintsData data = result.get("listItems"); + assertThat(data.maxPage).isEqualTo(100); + assertThat(data.maxSize).isEqualTo(50); + } + + @Test + public void scanPageableConstraints_exclusiveMinimum_addsOne() { + Schema pageSchema = new IntegerSchema(); + pageSchema.setMinimum(BigDecimal.valueOf(-1)); + pageSchema.setExclusiveMinimum(Boolean.TRUE); // exclusive -1 → effective min = 0 + + Schema sizeSchema = new IntegerSchema(); + sizeSchema.setMinimum(BigDecimal.valueOf(0)); + sizeSchema.setExclusiveMinimum(Boolean.TRUE); // exclusive 0 → effective min = 1 + + OpenAPI openAPI = buildPageableOperationWithParams(List.of( + new Parameter().name("page").schema(pageSchema), + new Parameter().name("size").schema(sizeSchema) + )); + + Map result = + SpringPageableScanUtils.scanPageableConstraints(openAPI, false); + + assertThat(result).containsKey("listItems"); + SpringPageableScanUtils.PageableConstraintsData data = result.get("listItems"); + assertThat(data.minPage).isEqualTo(0); + assertThat(data.minSize).isEqualTo(1); + } + + @Test + public void scanPageableConstraints_oas31NumericExclusive_subtractsOrAddsOne() { + Schema sizeSchema = new IntegerSchema(); + sizeSchema.setExclusiveMaximumValue(BigDecimal.valueOf(51)); // exclusive → effective max = 50 + sizeSchema.setExclusiveMinimumValue(BigDecimal.valueOf(0)); // exclusive → effective min = 1 + + OpenAPI openAPI = buildPageableOperationWithParams(List.of( + new Parameter().name("size").schema(sizeSchema) + )); + + Map result = + SpringPageableScanUtils.scanPageableConstraints(openAPI, false); + + assertThat(result).containsKey("listItems"); + SpringPageableScanUtils.PageableConstraintsData data = result.get("listItems"); + assertThat(data.maxSize).isEqualTo(50); + assertThat(data.minSize).isEqualTo(1); + } + + /** + * Builds an OpenAPI doc with a single GET /items operation marked x-spring-paginated. + */ + private static OpenAPI buildPageableOperation(Parameter sortParam) { + Operation op = new Operation(); + op.setOperationId("listItems"); + op.addExtension("x-spring-paginated", true); + op.addParametersItem(sortParam); + + PathItem pathItem = new PathItem(); + pathItem.setGet(op); + + Paths paths = new Paths(); + paths.addPathItem("/items", pathItem); + + OpenAPI openAPI = new OpenAPI(); + openAPI.setPaths(paths); + return openAPI; + } + + // ------------------------------------------------------------------------- + // scanSortValidationEnums — NPE regression for array schema without items + // ------------------------------------------------------------------------- + + /** + * Regression: array sort parameter with no {@code items} must not throw NPE. + * {@code isArraySchema()} returns {@code true} but {@code schema.getItems()} returns + * {@code null}, which would NPE on the subsequent {@code enumSchema.get$ref()} call + * before the fix. + * + *

+     * parameters:
+     *   - name: sort
+     *     in: query
+     *     schema:
+     *       type: array
+     *       # items: intentionally absent
+     * 
+ */ + @Test + public void scanSortValidationEnums_arraySchemaWithNoItems_doesNotThrow_and_returnsEmptyMap() { + // sort param: type=array but items intentionally absent + Schema sortSchema = new ArraySchema(); + // getItems() == null + assertThat(sortSchema.getItems()).isNull(); + Parameter sortParam = new Parameter().name("sort").schema(sortSchema); + OpenAPI openAPI = buildPageableOperation(sortParam); + + // does not throw NPE + assertThatCode(() -> SpringPageableScanUtils.scanSortValidationEnums(openAPI, false)) + .doesNotThrowAnyException(); + + // and returns empty map + Map> result = SpringPageableScanUtils.scanSortValidationEnums(openAPI, false); + assertThat(result).isEmpty(); + } + + // ------------------------------------------------------------------------- + // scanSortValidationEnums — happy path + // ------------------------------------------------------------------------- + + /** + *
+     * parameters:
+     *   - name: sort
+     *     in: query
+     *     schema:
+     *       type: array # sort as multi-column
+     *       items:
+     *         type: string
+     *         enum: ["name,asc", "name,desc", "id,asc"]
+     * 
+ */ + @Test + public void scanSortValidationEnums_arraySchemaWithEnumItems_returnsMappedEnums() { + Schema items = new StringSchema()._enum(List.of("name,asc", "name,desc", "id,asc")); + Schema sortSchema = new ArraySchema().items(items); + Parameter sortParam = new Parameter().name("sort").schema(sortSchema); + OpenAPI openAPI = buildPageableOperation(sortParam); + + Map> result = SpringPageableScanUtils.scanSortValidationEnums(openAPI, false); + assertThat(result) + .containsKey("listItems") + .satisfies(m -> assertThat(m.get("listItems")) + .containsExactly("name,asc", "name,desc", "id,asc")); + } + + /** + *
+     * parameters:
+     *   - name: sort
+     *     in: query
+     *     schema:
+     *       type: string # sort as single-column
+     *       enum: ["id,asc", "id,desc"]
+     * 
+ */ + @Test + public void scanSortValidationEnums_nonArraySortSchemaWithEnum_returnsIt() { + Schema sortSchema = new StringSchema()._enum(List.of("id,asc", "id,desc")); + Parameter sortParam = new Parameter().name("sort").schema(sortSchema); + OpenAPI openAPI = buildPageableOperation(sortParam); + + Map> result = SpringPageableScanUtils.scanSortValidationEnums(openAPI, false); + assertThat(result) + .containsKey("listItems") + .satisfies(m -> assertThat(m.get("listItems")).containsExactly("id,asc", "id,desc")); + } + + /** + *
+     * parameters:
+     *   - name: sort
+     *     in: query
+     *     schema:
+     *       type: string # sort as single-column
+     *       # enum: absent — no validation constraint
+     * 
+ */ + @Test + public void scanSortValidationEnums_sortSchemaWithNoEnum_returnsEmptyMap() { + Parameter sortParam = new Parameter().name("sort").schema(new StringSchema()); + OpenAPI openAPI = buildPageableOperation(sortParam); + + assertThat(SpringPageableScanUtils.scanSortValidationEnums(openAPI, false)).isEmpty(); + } + + // ------------------------------------------------------------------------- + // applyAutoXSpringPaginatedIfNeeded + // ------------------------------------------------------------------------- + + @Test + public void applyAutoXSpringPaginatedIfNeeded_allThreeParams_setsExtensionAndReturnsTrue() { + Operation op = new Operation(); + op.addParametersItem(new Parameter().name("page")); + op.addParametersItem(new Parameter().name("size")); + op.addParametersItem(new Parameter().name("sort")); + + boolean result = SpringPageableScanUtils.applyAutoXSpringPaginatedIfNeeded(op, true); + + assertThat(result).isTrue(); + assertThat(op.getExtensions()).containsEntry("x-spring-paginated", Boolean.TRUE); + } + + @Test + public void applyAutoXSpringPaginatedIfNeeded_missingOneParam_doesNotSetExtension() { + Operation op = new Operation(); + op.addParametersItem(new Parameter().name("page")); + op.addParametersItem(new Parameter().name("size")); + // 'sort' is absent + + boolean result = SpringPageableScanUtils.applyAutoXSpringPaginatedIfNeeded(op, true); + + assertThat(result).isFalse(); + assertThat(op.getExtensions()).isNull(); + } + + @Test + public void applyAutoXSpringPaginatedIfNeeded_autoDisabled_doesNotSetExtension() { + Operation op = new Operation(); + op.addParametersItem(new Parameter().name("page")); + op.addParametersItem(new Parameter().name("size")); + op.addParametersItem(new Parameter().name("sort")); + + boolean result = SpringPageableScanUtils.applyAutoXSpringPaginatedIfNeeded(op, false); + + assertThat(result).isFalse(); + assertThat(op.getExtensions()).isNull(); + } + + @Test + public void applyAutoXSpringPaginatedIfNeeded_explicitlyTrue_returnsTrueWithoutMutation() { + Operation op = new Operation(); + op.addExtension("x-spring-paginated", Boolean.TRUE); + // No params needed — already explicitly set + + boolean result = SpringPageableScanUtils.applyAutoXSpringPaginatedIfNeeded(op, false); + + assertThat(result).isTrue(); + // Extension was already true and must remain true + assertThat(op.getExtensions()).containsEntry("x-spring-paginated", Boolean.TRUE); + } + + @Test + public void applyAutoXSpringPaginatedIfNeeded_explicitlyFalse_returnsFalseAndIsNotOverridden() { + Operation op = new Operation(); + op.addExtension("x-spring-paginated", Boolean.FALSE); + op.addParametersItem(new Parameter().name("page")); + op.addParametersItem(new Parameter().name("size")); + op.addParametersItem(new Parameter().name("sort")); + + boolean result = SpringPageableScanUtils.applyAutoXSpringPaginatedIfNeeded(op, true); + + assertThat(result).isFalse(); + // Manual false must not be overridden by auto-detection + assertThat(op.getExtensions()).containsEntry("x-spring-paginated", Boolean.FALSE); + } + + @Test + public void applyAutoXSpringPaginatedIfNeeded_noParams_doesNotSetExtension() { + Operation op = new Operation(); + // No parameters at all + + boolean result = SpringPageableScanUtils.applyAutoXSpringPaginatedIfNeeded(op, true); + + assertThat(result).isFalse(); + assertThat(op.getExtensions()).isNull(); + } + + // ------------------------------------------------------------------------- + // applyPageableAnnotations + // ------------------------------------------------------------------------- + + private static CodegenOperation minimalOp(String operationId) { + CodegenOperation op = new CodegenOperation(); + op.operationId = operationId; + return op; + } + + @Test + public void applyPageableAnnotations_validPageable_java_formatsWithAttrs() { + CodegenOperation op = minimalOp("listItems"); + SpringPageableScanUtils.PageableConstraintsData constraints = + new SpringPageableScanUtils.PageableConstraintsData(100, 50, 0, 1); + Map registry = + Collections.singletonMap("listItems", constraints); + + SpringPageableScanUtils.applyPageableAnnotations(op, true, true, registry, + false, Collections.emptyMap(), Collections.emptyMap(), + SpringPageableScanUtils.AnnotationSyntax.JAVA); + + assertThat(op.vendorExtensions).containsKey("x-pageable-extra-annotation"); + List annotations = (List) op.vendorExtensions.get("x-pageable-extra-annotation"); + assertThat(annotations).hasSize(1); + assertThat(annotations.get(0)) + .startsWith("@ValidPageable(") + .contains("maxPage = 100") + .contains("maxSize = 50") + .contains("minPage = 0") + .contains("minSize = 1"); + assertThat(op.imports).contains("ValidPageable"); + } + + @Test + public void applyPageableAnnotations_validSort_javaSyntax_usesCurlyBraces() { + CodegenOperation op = minimalOp("listItems"); + Map> sortEnums = Collections.singletonMap("listItems", + List.of("name,asc", "name,desc")); + + SpringPageableScanUtils.applyPageableAnnotations(op, false, true, Collections.emptyMap(), + true, sortEnums, Collections.emptyMap(), + SpringPageableScanUtils.AnnotationSyntax.JAVA); + + List annotations = (List) op.vendorExtensions.get("x-pageable-extra-annotation"); + assertThat(annotations).hasSize(1); + assertThat(annotations.get(0)) + .isEqualTo("@ValidSort(allowedValues = {\"name,asc\", \"name,desc\"})"); + assertThat(op.imports).contains("ValidSort"); + } + + @Test + public void applyPageableAnnotations_validSort_kotlinSyntax_usesSquareBrackets() { + CodegenOperation op = minimalOp("listItems"); + Map> sortEnums = Collections.singletonMap("listItems", + List.of("name,asc", "name,desc")); + + SpringPageableScanUtils.applyPageableAnnotations(op, false, true, Collections.emptyMap(), + true, sortEnums, Collections.emptyMap(), + SpringPageableScanUtils.AnnotationSyntax.KOTLIN); + + List annotations = (List) op.vendorExtensions.get("x-pageable-extra-annotation"); + assertThat(annotations).hasSize(1); + assertThat(annotations.get(0)) + .isEqualTo("@ValidSort(allowedValues = [\"name,asc\", \"name,desc\"])"); + } + + @Test + public void applyPageableAnnotations_pageableDefault_pageAndSize() { + CodegenOperation op = minimalOp("listItems"); + SpringPageableScanUtils.PageableDefaultsData defaults = + new SpringPageableScanUtils.PageableDefaultsData(0, 20, Collections.emptyList()); + Map registry = + Collections.singletonMap("listItems", defaults); + + SpringPageableScanUtils.applyPageableAnnotations(op, false, false, Collections.emptyMap(), + false, Collections.emptyMap(), registry, + SpringPageableScanUtils.AnnotationSyntax.JAVA); + + List annotations = (List) op.vendorExtensions.get("x-pageable-extra-annotation"); + assertThat(annotations).hasSize(1); + assertThat(annotations.get(0)).isEqualTo("@PageableDefault(page = 0, size = 20)"); + assertThat(op.imports).contains("PageableDefault"); + } + + @Test + public void applyPageableAnnotations_sortDefault_javaSyntax() { + CodegenOperation op = minimalOp("listItems"); + List sortFields = List.of( + new SpringPageableScanUtils.SortFieldDefault("name", "ASC"), + new SpringPageableScanUtils.SortFieldDefault("id", "DESC") + ); + SpringPageableScanUtils.PageableDefaultsData defaults = + new SpringPageableScanUtils.PageableDefaultsData(null, null, sortFields); + Map registry = + Collections.singletonMap("listItems", defaults); + + SpringPageableScanUtils.applyPageableAnnotations(op, false, false, Collections.emptyMap(), + false, Collections.emptyMap(), registry, + SpringPageableScanUtils.AnnotationSyntax.JAVA); + + List annotations = (List) op.vendorExtensions.get("x-pageable-extra-annotation"); + assertThat(annotations).hasSize(1); + assertThat(annotations.get(0)) + .isEqualTo("@SortDefault.SortDefaults({" + + "@SortDefault(sort = {\"name\"}, direction = Sort.Direction.ASC), " + + "@SortDefault(sort = {\"id\"}, direction = Sort.Direction.DESC)})"); + assertThat(op.imports).containsAll(List.of("SortDefault", "Sort")); + } + + @Test + public void applyPageableAnnotations_sortDefault_kotlinSyntax() { + CodegenOperation op = minimalOp("listItems"); + List sortFields = List.of( + new SpringPageableScanUtils.SortFieldDefault("name", "ASC") + ); + SpringPageableScanUtils.PageableDefaultsData defaults = + new SpringPageableScanUtils.PageableDefaultsData(null, null, sortFields); + Map registry = + Collections.singletonMap("listItems", defaults); + + SpringPageableScanUtils.applyPageableAnnotations(op, false, false, Collections.emptyMap(), + false, Collections.emptyMap(), registry, + SpringPageableScanUtils.AnnotationSyntax.KOTLIN); + + List annotations = (List) op.vendorExtensions.get("x-pageable-extra-annotation"); + assertThat(annotations).hasSize(1); + assertThat(annotations.get(0)) + .isEqualTo("@SortDefault.SortDefaults(SortDefault(sort = [\"name\"], direction = Sort.Direction.ASC))"); + } + + @Test + public void applyPageableAnnotations_noMatchingRegistryEntries_noAnnotationsAdded() { + CodegenOperation op = minimalOp("someOtherOp"); + + SpringPageableScanUtils.applyPageableAnnotations(op, true, true, + Collections.singletonMap("differentOp", new SpringPageableScanUtils.PageableConstraintsData(10, 5, 0, 1)), + true, + Collections.singletonMap("differentOp", List.of("id,asc")), + Collections.singletonMap("differentOp", new SpringPageableScanUtils.PageableDefaultsData(0, 10, Collections.emptyList())), + SpringPageableScanUtils.AnnotationSyntax.JAVA); + + assertThat(op.vendorExtensions).doesNotContainKey("x-pageable-extra-annotation"); + assertThat(op.imports).isEmpty(); + } + + // ------------------------------------------------------------------------- + // applySpringDocPageableAnnotation + // ------------------------------------------------------------------------- + + @Test + public void applySpringDocPageableAnnotation_javaSyntax_springDoc_addsParameterObjectImport() { + CodegenOperation op = minimalOp("listItems"); + + SpringPageableScanUtils.applySpringDocPageableAnnotation( + op, SpringPageableScanUtils.AnnotationSyntax.JAVA, true); + + assertThat(op.imports).contains("ParameterObject"); + assertThat(op.imports).doesNotContain("PageableAsQueryParam"); + assertThat(op.vendorExtensions).doesNotContainKey("x-operation-extra-annotation"); + } + + @Test + public void applySpringDocPageableAnnotation_kotlinSyntax_springDoc_addsImportAndPrependsAnnotation() { + CodegenOperation op = minimalOp("listItems"); + + SpringPageableScanUtils.applySpringDocPageableAnnotation( + op, SpringPageableScanUtils.AnnotationSyntax.KOTLIN, true); + + assertThat(op.imports).contains("PageableAsQueryParam"); + assertThat(op.imports).doesNotContain("ParameterObject"); + List extraAnnotations = (List) op.vendorExtensions.get("x-operation-extra-annotation"); + assertThat(extraAnnotations).containsExactly("@PageableAsQueryParam"); + } + + @Test + public void applySpringDocPageableAnnotation_kotlinSyntax_springDoc_prependsToExistingAnnotations() { + CodegenOperation op = minimalOp("listItems"); + List existing = new ArrayList<>(); + existing.add("@PreAuthorize(\"hasRole('ADMIN')\")"); + op.vendorExtensions.put("x-operation-extra-annotation", existing); + + SpringPageableScanUtils.applySpringDocPageableAnnotation( + op, SpringPageableScanUtils.AnnotationSyntax.KOTLIN, true); + + List extraAnnotations = (List) op.vendorExtensions.get("x-operation-extra-annotation"); + assertThat(extraAnnotations).containsExactly("@PageableAsQueryParam", "@PreAuthorize(\"hasRole('ADMIN')\")"); + } + + @Test + public void applySpringDocPageableAnnotation_notSpringDoc_isNoOp() { + CodegenOperation op = minimalOp("listItems"); + + SpringPageableScanUtils.applySpringDocPageableAnnotation( + op, SpringPageableScanUtils.AnnotationSyntax.KOTLIN, false); + + assertThat(op.imports).isEmpty(); + assertThat(op.vendorExtensions).doesNotContainKey("x-operation-extra-annotation"); + } + + // ------------------------------------------------------------------------- + // Instance: scanAll + applyPageableAnnotations + // ------------------------------------------------------------------------- + + @Test + public void scanAll_populatesInstanceMaps() { + Parameter pageParam = new Parameter().name("page").schema(new IntegerSchema()); + Parameter sizeParam = new Parameter().name("size").schema(new IntegerSchema()); + Parameter sortParam = new Parameter().name("sort").schema( + new StringSchema().addEnumItem("name,asc").addEnumItem("name,desc")); + OpenAPI openAPI = buildPageableOperationWithParams(List.of(pageParam, sizeParam, sortParam)); + + SpringPageableScanUtils utils = new SpringPageableScanUtils(); + utils.scanAll(openAPI, false); // auto-detect disabled; x-spring-paginated already set + + assertThat(utils.sortValidationEnums).containsKey("listItems"); + assertThat(utils.sortValidationEnums.get("listItems")).containsExactly("name,asc", "name,desc"); + // No page/size defaults or constraints in this spec + assertThat(utils.pageableDefaultsRegistry).doesNotContainKey("listItems"); + assertThat(utils.pageableConstraintsRegistry).doesNotContainKey("listItems"); + } + + @Test + public void instanceApplyPageableAnnotations_usesStoredMaps() { + CodegenOperation op = minimalOp("listItems"); + Map> sortEnums = Collections.singletonMap("listItems", List.of("id,asc")); + + SpringPageableScanUtils utils = new SpringPageableScanUtils(); + utils.sortValidationEnums = sortEnums; + + utils.applyPageableAnnotations(op, false, true, true, SpringPageableScanUtils.AnnotationSyntax.JAVA); + + List annotations = (List) op.vendorExtensions.get("x-pageable-extra-annotation"); + assertThat(annotations).hasSize(1); + assertThat(annotations.get(0)).isEqualTo("@ValidSort(allowedValues = {\"id,asc\"})"); + assertThat(op.imports).contains("ValidSort"); + } +} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java index 906ace829bcb..2fcb885409d3 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java @@ -757,4 +757,428 @@ public void getParentNameMultipleInterfacesTest() { Schema composedSchema = allSchemas.get("RandomAnimalsResponse_animals_inner"); assertNull(ModelUtils.getParentName(composedSchema, allSchemas)); } + + // ------------------------------------------------------------------------- + // resolveMaximumBound + // ------------------------------------------------------------------------- + + @Test + public void resolveMaximumBound_nullSchema_returnsNull() { + assertNull(ModelUtils.resolveMaximumBound(new OpenAPI(), null)); + } + + @Test + public void resolveMaximumBound_noMaximumDefined_returnsNull() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + assertNull(ModelUtils.resolveMaximumBound(openAPI, schema)); + } + + @Test + public void resolveMaximumBound_inclusiveMaximum_returnsInclusiveBound() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMaximum(BigDecimal.valueOf(100)); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(100), bound.maxBound); + assertFalse(bound.exclusive); + } + + @Test + public void resolveMaximumBound_exclusiveMaximum_returnsExclusiveBound() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMaximum(BigDecimal.valueOf(50)); + schema.setExclusiveMaximum(Boolean.TRUE); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(50), bound.maxBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMaximumBound_oas31NumericExclusive_returnsExclusiveBound() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setExclusiveMaximumValue(BigDecimal.valueOf(10)); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(10), bound.maxBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMaximumBound_oas31NumericExclusiveStricterThanInclusive_exclusiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMaximum(BigDecimal.valueOf(100)); // inclusive 100 + schema.setExclusiveMaximumValue(BigDecimal.valueOf(80)); // exclusive 80 is stricter + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(80), bound.maxBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMaximumBound_oas31NumericExclusiveLooseThanInclusive_inclusiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMaximum(BigDecimal.valueOf(50)); // inclusive 50 is stricter + schema.setExclusiveMaximumValue(BigDecimal.valueOf(90)); // exclusive 90 is looser + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(50), bound.maxBound); + assertFalse(bound.exclusive); + } + + @Test + public void resolveMaximumBound_sameValueInclusiveAndExclusive_exclusiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // maximum=50 (inclusive) + exclusiveMaximumValue=50 → exclusive 50 is stricter + Schema schema = new IntegerSchema(); + schema.setMaximum(BigDecimal.valueOf(50)); + schema.setExclusiveMaximumValue(BigDecimal.valueOf(50)); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(50), bound.maxBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMaximumBound_refToSchemaWithMaximum_resolvesRef() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema refTarget = new IntegerSchema(); + refTarget.setMaximum(BigDecimal.valueOf(50)); + refTarget.setExclusiveMaximum(Boolean.TRUE); + openAPI.getComponents().addSchemas("MyInt", refTarget); + + Schema ref = new Schema<>().$ref("#/components/schemas/MyInt"); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, ref); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(50), bound.maxBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMaximumBound_allOf_returnsMostRestrictive() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema loose = new IntegerSchema(); + loose.setMaximum(BigDecimal.valueOf(200)); + openAPI.getComponents().addSchemas("Loose", loose); + + Schema strict = new IntegerSchema(); + strict.setMaximum(BigDecimal.valueOf(50)); + openAPI.getComponents().addSchemas("Strict", strict); + + Schema schema = new Schema<>().allOf(Arrays.asList( + new Schema<>().$ref("#/components/schemas/Loose"), + new Schema<>().$ref("#/components/schemas/Strict") + )); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(50), bound.maxBound); + assertFalse(bound.exclusive); + } + + @Test + public void resolveMaximumBound_allOfSameValueDifferentExclusivity_exclusiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // allOf: max=50 inclusive vs max=50 exclusive — exclusive is stricter + Schema inclusive = new IntegerSchema(); + inclusive.setMaximum(BigDecimal.valueOf(50)); + openAPI.getComponents().addSchemas("Inclusive", inclusive); + + Schema exclusive = new IntegerSchema(); + exclusive.setMaximum(BigDecimal.valueOf(50)); + exclusive.setExclusiveMaximum(Boolean.TRUE); + openAPI.getComponents().addSchemas("Exclusive", exclusive); + + Schema schema = new Schema<>().allOf(Arrays.asList( + new Schema<>().$ref("#/components/schemas/Inclusive"), + new Schema<>().$ref("#/components/schemas/Exclusive") + )); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(50), bound.maxBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMaximumBound_inlineAndAllOf_mostRestrictiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema allOfItem = new IntegerSchema(); + allOfItem.setMaximum(BigDecimal.valueOf(30)); + openAPI.getComponents().addSchemas("Base", allOfItem); + + Schema schema = new IntegerSchema(); + schema.setMaximum(BigDecimal.valueOf(100)); + schema.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(30), bound.maxBound); + } + + @Test + public void resolveMaximumBound_allOfItemWithoutMaximum_ignored() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + openAPI.getComponents().addSchemas("NoMax", new IntegerSchema()); + + Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/NoMax"))); + assertNull(ModelUtils.resolveMaximumBound(openAPI, schema)); + } + + @Test + public void resolveMaximumBound_nestedAllOf_recurses() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // Grandparent defines maximum=50 + Schema grandparent = new IntegerSchema(); + grandparent.setMaximum(BigDecimal.valueOf(50)); + openAPI.getComponents().addSchemas("Grandparent", grandparent); + + // Parent has allOf → Grandparent (no direct maximum) + Schema parent = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Grandparent"))); + openAPI.getComponents().addSchemas("Parent", parent); + + // Child has allOf → Parent + Schema child = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Parent"))); + + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, child); + assertNotNull(bound); + assertEquals(bound.maxBound, BigDecimal.valueOf(50)); + assertFalse(bound.exclusive); + } + + @Test + public void resolveMaximumBound_nestedAllOf_mostRestrictiveAcrossAllLevels() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // Grandparent defines maximum=30 (stricter) + Schema grandparent = new IntegerSchema(); + grandparent.setMaximum(BigDecimal.valueOf(30)); + openAPI.getComponents().addSchemas("Grandparent", grandparent); + + // Parent defines maximum=100 and also allOf → Grandparent + Schema parent = new IntegerSchema(); + parent.setMaximum(BigDecimal.valueOf(100)); + parent.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Grandparent"))); + openAPI.getComponents().addSchemas("Parent", parent); + + // Child allOf → Parent + Schema child = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Parent"))); + + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, child); + assertNotNull(bound); + assertEquals(bound.maxBound, BigDecimal.valueOf(30)); + } + + // ------------------------------------------------------------------------- + // resolveMinimumBound + // ------------------------------------------------------------------------- + + @Test + public void resolveMinimumBound_nullSchema_returnsNull() { + assertNull(ModelUtils.resolveMinimumBound(new OpenAPI(), null)); + } + + @Test + public void resolveMinimumBound_noMinimumDefined_returnsNull() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + assertNull(ModelUtils.resolveMinimumBound(openAPI, new IntegerSchema())); + } + + @Test + public void resolveMinimumBound_inclusiveMinimum_returnsInclusiveBound() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMinimum(BigDecimal.valueOf(1)); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(1), bound.minBound); + assertFalse(bound.exclusive); + } + + @Test + public void resolveMinimumBound_exclusiveMinimum_returnsExclusiveBound() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMinimum(BigDecimal.valueOf(0)); + schema.setExclusiveMinimum(Boolean.TRUE); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(0), bound.minBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMinimumBound_oas31NumericExclusive_returnsExclusiveBound() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setExclusiveMinimumValue(BigDecimal.valueOf(5)); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(5), bound.minBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMinimumBound_oas31NumericExclusiveStricterThanInclusive_exclusiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMinimum(BigDecimal.valueOf(0)); // inclusive 0 + schema.setExclusiveMinimumValue(BigDecimal.valueOf(3)); // exclusive 3 is stricter + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(3), bound.minBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMinimumBound_oas31NumericExclusiveLooseThanInclusive_inclusiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMinimum(BigDecimal.valueOf(10)); // inclusive 10 is stricter + schema.setExclusiveMinimumValue(BigDecimal.valueOf(2)); // exclusive 2 is looser + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(10), bound.minBound); + assertFalse(bound.exclusive); + } + + @Test + public void resolveMinimumBound_sameValueInclusiveAndExclusive_exclusiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMinimum(BigDecimal.valueOf(5)); + schema.setExclusiveMinimumValue(BigDecimal.valueOf(5)); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(5), bound.minBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMinimumBound_refToSchemaWithMinimum_resolvesRef() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema refTarget = new IntegerSchema(); + refTarget.setMinimum(BigDecimal.valueOf(5)); + openAPI.getComponents().addSchemas("MyInt", refTarget); + + Schema ref = new Schema<>().$ref("#/components/schemas/MyInt"); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, ref); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(5), bound.minBound); + assertFalse(bound.exclusive); + } + + @Test + public void resolveMinimumBound_allOf_returnsMostRestrictive() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema permissive = new IntegerSchema(); + permissive.setMinimum(BigDecimal.valueOf(1)); + openAPI.getComponents().addSchemas("Permissive", permissive); + + Schema strict = new IntegerSchema(); + strict.setMinimum(BigDecimal.valueOf(10)); + openAPI.getComponents().addSchemas("Strict", strict); + + Schema schema = new Schema<>().allOf(Arrays.asList( + new Schema<>().$ref("#/components/schemas/Permissive"), + new Schema<>().$ref("#/components/schemas/Strict") + )); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(10), bound.minBound); + assertFalse(bound.exclusive); + } + + @Test + public void resolveMinimumBound_allOfSameValueDifferentExclusivity_exclusiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // allOf: min=5 inclusive vs min=5 exclusive — exclusive is stricter + Schema inclusive = new IntegerSchema(); + inclusive.setMinimum(BigDecimal.valueOf(5)); + openAPI.getComponents().addSchemas("Inclusive", inclusive); + + Schema exclusive = new IntegerSchema(); + exclusive.setMinimum(BigDecimal.valueOf(5)); + exclusive.setExclusiveMinimum(Boolean.TRUE); + openAPI.getComponents().addSchemas("Exclusive", exclusive); + + Schema schema = new Schema<>().allOf(Arrays.asList( + new Schema<>().$ref("#/components/schemas/Inclusive"), + new Schema<>().$ref("#/components/schemas/Exclusive") + )); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(5), bound.minBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMinimumBound_inlineAndAllOf_mostRestrictiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema allOfItem = new IntegerSchema(); + allOfItem.setMinimum(BigDecimal.valueOf(20)); + openAPI.getComponents().addSchemas("Base", allOfItem); + + Schema schema = new IntegerSchema(); + schema.setMinimum(BigDecimal.valueOf(0)); + schema.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(20), bound.minBound); + } + + @Test + public void resolveMinimumBound_allOfItemWithoutMinimum_ignored() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + openAPI.getComponents().addSchemas("NoMin", new IntegerSchema()); + + Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/NoMin"))); + assertNull(ModelUtils.resolveMinimumBound(openAPI, schema)); + } + + @Test + public void resolveMinimumBound_nestedAllOf_recurses() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // Grandparent defines minimum=10 + Schema grandparent = new IntegerSchema(); + grandparent.setMinimum(BigDecimal.valueOf(10)); + openAPI.getComponents().addSchemas("Grandparent", grandparent); + + // Parent has allOf → Grandparent (no direct minimum) + Schema parent = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Grandparent"))); + openAPI.getComponents().addSchemas("Parent", parent); + + // Child has allOf → Parent + Schema child = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Parent"))); + + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, child); + assertNotNull(bound); + assertEquals(bound.minBound, BigDecimal.valueOf(10)); + assertFalse(bound.exclusive); + } + + @Test + public void resolveMinimumBound_nestedAllOf_mostRestrictiveAcrossAllLevels() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // Grandparent defines minimum=20 (stricter) + Schema grandparent = new IntegerSchema(); + grandparent.setMinimum(BigDecimal.valueOf(20)); + openAPI.getComponents().addSchemas("Grandparent", grandparent); + + // Parent defines minimum=5 and also allOf → Grandparent + Schema parent = new IntegerSchema(); + parent.setMinimum(BigDecimal.valueOf(5)); + parent.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Grandparent"))); + openAPI.getComponents().addSchemas("Parent", parent); + + // Child allOf → Parent + Schema child = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Parent"))); + + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, child); + assertNotNull(bound); + assertEquals(bound.minBound, BigDecimal.valueOf(20)); + } + } diff --git a/modules/openapi-generator/src/test/resources/3_0/spring/enum-value-interface.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/enum-value-interface.yaml new file mode 100644 index 000000000000..a59e6049cccc --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/spring/enum-value-interface.yaml @@ -0,0 +1,42 @@ +openapi: 3.0.0 +info: + title: Enum Value Interface Test + version: 1.0.0 +paths: + /orders: + get: + operationId: listOrders + parameters: + - name: status + in: query + schema: + $ref: '#/components/schemas/OrderStatus' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Order' +components: + schemas: + # Top-level enum schema + OrderStatus: + type: string + enum: + - placed + - approved + - delivered + # Model with an inline enum property + Order: + type: object + properties: + id: + type: integer + format: int64 + priority: + type: string + enum: + - low + - medium + - high diff --git a/modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics-domain.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics-domain.yaml new file mode 100644 index 000000000000..cc5e40164083 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics-domain.yaml @@ -0,0 +1,41 @@ +openapi: "3.0.1" +info: + title: Petstore Generics - Domain Schemas + version: 1.0.0 +components: + schemas: + Pet: + type: object + required: [name] + properties: + id: + type: integer + format: int64 + name: + type: string + species: + type: string + + LogEntryData: + type: object + description: Payload for a log entry + properties: + level: + type: string + enum: [DEBUG, INFO, WARN, ERROR] + message: + type: string + source: + type: string + + MetricsEntryData: + type: object + description: Payload for a metrics entry + properties: + metricName: + type: string + value: + type: number + format: double + unit: + type: string diff --git a/modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics-inheritance.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics-inheritance.yaml new file mode 100644 index 000000000000..22783d4a803b --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics-inheritance.yaml @@ -0,0 +1,87 @@ +openapi: 3.0.1 +info: + title: OpenAPI Petstore - Generics Inheritance Test + description: | + Minimal spec for testing the suppression safety check: when a generic-instance schema + (UserResponse) is used as a parent class (via allOf) by another schema + (ExtendedUserResponse), the safety check must prevent suppression of UserResponse. + version: 1.0.0 +servers: + - url: http://localhost:8080 +tags: + - name: response + description: Response operations + +paths: + /users/{id}/response: + get: + tags: [response] + operationId: getUserResponse + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + description: User response + + /users/{id}/extended-response: + get: + tags: [response] + operationId: getExtendedUserResponse + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ExtendedUserResponse' + description: Extended user response + +components: + schemas: + + User: + type: object + properties: + id: + type: string + name: + type: string + + # Tier 2 — suffix=Response, slot=data: matched as ApiResponse + # Safety check scenario: this schema is also inherited by ExtendedUserResponse + # → must NOT be suppressed despite being a detected generic instance + UserResponse: + type: object + required: [data, status] + properties: + data: + $ref: '#/components/schemas/User' + status: + type: string + message: + type: string + + # Inherits UserResponse via allOf — NOT itself matched as a generic instance + # (resolveProperties only sees inline allOf properties → {cacheControl}, no 'data' slot) + # model.parent = "UserResponse" → triggers suppression safety check + ExtendedUserResponse: + allOf: + - $ref: '#/components/schemas/UserResponse' + - type: object + properties: + cacheControl: + type: string + description: Cache-Control header value diff --git a/modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics-shared.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics-shared.yaml new file mode 100644 index 000000000000..1271b2c4c9f7 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics-shared.yaml @@ -0,0 +1,46 @@ +openapi: "3.0.1" +info: + title: Petstore Generics - Shared Schemas + version: 1.0.0 +components: + schemas: + User: + type: object + properties: + id: + type: string + name: + type: string + email: + type: string + + Order: + type: object + required: [orderId] + properties: + orderId: + type: string + quantity: + type: integer + format: int32 + totalPrice: + type: number + format: double + + PageMeta: + type: object + description: Pagination metadata + properties: + size: + type: integer + format: int64 + number: + type: integer + format: int64 + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int64 + required: [size, number, totalElements, totalPages] diff --git a/modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics.yaml new file mode 100644 index 000000000000..7294e36d8bdf --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics.yaml @@ -0,0 +1,652 @@ +openapi: 3.0.1 +info: + title: OpenAPI Petstore - Generics Test + description: | + Test spec for the genericPatterns / generics-support feature. + + Detection tiers exercised: + - Tier 1 (vendor extension): UserVendorResult + - Tier 2 (suffix pattern "Response", slot "data"): UserResponse, PetResponse, OrderResponse + - Tier 2 (suffix pattern "Page", slotArray "content"): UserPage (flat), PetPage (allOf) + - Tier 3 (discovery / structural clustering): LogEntry, MetricsEntry + - NOT matched: SearchResult (unique structure) + + External file references: + - Order schema referenced from petstore-generics-shared.yaml + - Pet, LogEntryData, MetricsEntryData referenced from petstore-generics-domain.yaml + version: 1.0.0 +servers: + - url: http://localhost:8080 +tags: + - name: response + description: Operations returning Response-wrapped domain objects + - name: page + description: Operations returning Page-wrapped domain objects + - name: vendor + description: Operations using vendor-extension generics + - name: result + description: Operations returning two-type-param Result wrappers + - name: observability + description: Log and metrics operations + - name: search + description: Search operations + +paths: + /users/{id}/response: + get: + tags: [response] + summary: Get user response (Tier 2 — suffix Response, slot data) + operationId: getUserResponse + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: User wrapped in ApiResponse + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + + /pets/{id}/response: + get: + tags: [response] + summary: Get pet response (Tier 2 — suffix Response, slot data, external domain $ref) + operationId: getPetResponse + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Pet wrapped in ApiResponse + content: + application/json: + schema: + $ref: '#/components/schemas/PetResponse' + + /orders/{id}/response: + get: + tags: [response] + summary: Get order response (Tier 2 — suffix Response, slot data, external shared $ref) + operationId: getOrderResponse + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Order wrapped in ApiResponse + content: + application/json: + schema: + $ref: '#/components/schemas/OrderResponse' + + /users: + get: + tags: [page] + summary: List users (Tier 2 — suffix Page, slotArray content, flat-object form) + operationId: listUsers + parameters: + - name: page + in: query + schema: + type: integer + default: 0 + - name: size + in: query + schema: + type: integer + default: 20 + responses: + '200': + description: Paged list of users + content: + application/json: + schema: + $ref: '#/components/schemas/UserPage' + + /pets: + get: + tags: [page] + summary: List pets (Tier 2 — suffix Page, slotArray content, allOf form) + operationId: listPets + parameters: + - name: page + in: query + schema: + type: integer + default: 0 + - name: size + in: query + schema: + type: integer + default: 20 + responses: + '200': + description: Paged list of pets + content: + application/json: + schema: + $ref: '#/components/schemas/PetPage' + + /vendor/user-result: + get: + tags: [vendor] + summary: Get vendor user result (Tier 1 — x-generic-class vendor extension) + operationId: getVendorUserResult + responses: + '200': + description: User result using vendor-extension-defined generic class + content: + application/json: + schema: + $ref: '#/components/schemas/UserVendorResult' + + /users/{id}/error-result: + get: + tags: [result] + summary: Get user error result (Tier 2 — multi-param slots, data:T + error:E) + operationId: getUserErrorResult + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: User wrapped in Result + content: + application/json: + schema: + $ref: '#/components/schemas/UserErrorResult' + + /orders/{id}/error-result: + get: + tags: [result] + summary: Get order error result (Tier 2 — multi-param slots, data:T + error:E) + operationId: getOrderErrorResult + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Order wrapped in Result + content: + application/json: + schema: + $ref: '#/components/schemas/OrderErrorResult' + + /users/{id}/response-error-result: + get: + tags: [result] + summary: Get user response-error result (Tier 2 recursive slot - data arg is itself a generic instance) + operationId: getUserResponseErrorResult + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: UserResponse (ApiResponse) wrapped in Result + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponseErrorResult' + + /users/responses/page: + get: + tags: [page] + summary: List user responses (Tier 2 recursive - Page expands to Page>) + operationId: listUserResponses + responses: + '200': + description: Paged list of user responses + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponsePage' + + /orders/{id}/details: + get: + tags: [response] + summary: Get order details (non-generic model with generic-instance property) + operationId: getOrderDetails + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Order details (userResult property should be substituted) + content: + application/json: + schema: + $ref: '#/components/schemas/OrderDetails' + + /notifications: + get: + tags: [response] + summary: Get notification batch (non-generic model with array-of-generic-instance property) + operationId: getNotificationBatch + responses: + '200': + description: Batch of user response notifications + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationBatch' + + + /logs/latest: + get: + tags: [observability] + summary: Get latest log entry (Tier 3 — structural cluster detection, NOT substituted) + operationId: getLatestLogEntry + responses: + '200': + description: Log entry + content: + application/json: + schema: + $ref: '#/components/schemas/LogEntry' + + /search: + get: + tags: [search] + summary: Search (SearchResult NOT matched by any pattern) + operationId: search + parameters: + - name: q + in: query + schema: + type: string + responses: + '200': + description: Search result + content: + application/json: + schema: + $ref: '#/components/schemas/SearchResult' + +components: + schemas: + + # ----------------------------------------------------------------------- + # Domain types — inline definitions (some with parallel external variants) + # ----------------------------------------------------------------------- + + User: + type: object + properties: + id: + type: string + name: + type: string + email: + type: string + + Pet: + type: object + required: [name] + properties: + id: + type: integer + format: int64 + name: + type: string + species: + type: string + + PageMeta: + type: object + description: Pagination metadata (inline) + properties: + size: + type: integer + format: int64 + number: + type: integer + format: int64 + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int64 + required: [size, number, totalElements, totalPages] + + LogEntryData: + type: object + description: Payload for a log entry + properties: + level: + type: string + message: + type: string + source: + type: string + + MetricsEntryData: + type: object + description: Payload for a metrics entry + properties: + metricName: + type: string + value: + type: number + format: double + unit: + type: string + + # ----------------------------------------------------------------------- + # Tier 2 — Response suffix, slot: data + # All three have identical structure except the $ref in 'data' + # ----------------------------------------------------------------------- + + UserResponse: + type: object + description: User wrapped in a generic ApiResponse + required: [data, status] + properties: + data: + $ref: '#/components/schemas/User' + status: + type: string + description: Response status code string + message: + type: string + description: Human-readable response message + + PetResponse: + type: object + description: Pet wrapped in a generic ApiResponse + required: [data, status] + properties: + data: + $ref: '#/components/schemas/Pet' + status: + type: string + description: Response status code string + message: + type: string + description: Human-readable response message + + OrderResponse: + type: object + description: | + Order wrapped in a generic ApiResponse. + The Order domain type is defined in the external shared schemas file. + required: [data, status] + properties: + data: + $ref: './petstore-generics-shared.yaml#/components/schemas/Order' + status: + type: string + message: + type: string + + # ----------------------------------------------------------------------- + # Tier 2 — Page suffix, slotArray: content + # ----------------------------------------------------------------------- + + UserPage: + type: object + description: Paged list of users — flat-object form + required: [content, page] + properties: + content: + type: array + items: + $ref: '#/components/schemas/User' + page: + $ref: '#/components/schemas/PageMeta' + + PetPage: + description: Paged list of pets — allOf form + allOf: + - $ref: '#/components/schemas/PageMeta' + - type: object + properties: + content: + type: array + items: + $ref: '#/components/schemas/Pet' + + # ----------------------------------------------------------------------- + # Tier 1 — Vendor extension: x-generic-class / x-generic-args + # ----------------------------------------------------------------------- + + UserVendorResult: + x-generic-class: "com.example.generic.VendorResult" + x-generic-args: + payload: User + type: object + description: User result using vendor-extension-defined generic class + required: [payload] + properties: + payload: + $ref: '#/components/schemas/User' + code: + type: integer + format: int32 + description: Vendor-specific result code + + # ----------------------------------------------------------------------- + # Tier 2 — ErrorResult suffix, slots: data→T + error→E (multi-param) + # UserErrorResult and OrderErrorResult both have same structure but + # both 'data' and 'error' vary → mapped to Result + # ----------------------------------------------------------------------- + + ValidationError: + type: object + description: Validation error details + required: [field, message] + properties: + field: + type: string + description: Name of the field that failed validation + message: + type: string + description: Human-readable validation error message + code: + type: string + description: Machine-readable error code + + PaymentError: + type: object + description: Payment processing error details + required: [reason, amount] + properties: + reason: + type: string + description: Reason for payment failure + amount: + type: number + format: double + description: Amount that failed to process + retryable: + type: boolean + description: Whether the payment can be retried + + UserErrorResult: + type: object + description: | + User operation result with structured error. + Matched by suffix=ErrorResult, slots: data→T, error→E → Result + required: [data] + properties: + data: + $ref: '#/components/schemas/User' + error: + $ref: '#/components/schemas/ValidationError' + success: + type: boolean + description: Whether the operation succeeded + + OrderErrorResult: + type: object + description: | + Order operation result with payment error. + Matched by suffix=ErrorResult, slots: data→T, error→E → Result + required: [data] + properties: + data: + $ref: './petstore-generics-shared.yaml#/components/schemas/Order' + error: + $ref: '#/components/schemas/PaymentError' + success: + type: boolean + description: Whether the operation succeeded + + # ----------------------------------------------------------------------- + # Tier 3 — Structural clustering (discovery only, NOT substituted) + # LogEntry and MetricsEntry have same structure except 'data' property + # ----------------------------------------------------------------------- + + LogEntry: + type: object + description: | + Log entry wrapper. + Same structure as MetricsEntry except 'data' points to LogEntryData. + These two form a Tier 3 cluster suggestion. + properties: + data: + $ref: '#/components/schemas/LogEntryData' + severity: + type: string + timestamp: + type: string + format: date-time + + MetricsEntry: + type: object + description: | + Metrics entry wrapper. + Same structure as LogEntry except 'data' points to MetricsEntryData. + These two form a Tier 3 cluster suggestion. + properties: + data: + $ref: '#/components/schemas/MetricsEntryData' + severity: + type: string + timestamp: + type: string + format: date-time + + # ----------------------------------------------------------------------- + # NOT MATCHED — unique structure, should not be suggested as generic + # ----------------------------------------------------------------------- + + SearchResult: + type: object + description: Search result — unique structure, not matched by any pattern + properties: + query: + type: string + totalHits: + type: integer + format: int64 + results: + type: array + items: + type: string + facets: + type: object + additionalProperties: + type: integer + + # ----------------------------------------------------------------------- + # NEW: UserResponseErrorResult — suffix=ErrorResult, slots: data→T + error→E + # data refs UserResponse (which is itself a generic instance → ApiResponse) + # Recursive expansion: Result, ValidationError> + # ----------------------------------------------------------------------- + + UserResponseErrorResult: + type: object + description: | + Result whose data slot is a UserResponse (itself a generic instance). + Tests recursive multi-param expansion: + UserResponseErrorResult → Result, ValidationError> + required: [data] + properties: + data: + $ref: '#/components/schemas/UserResponse' + error: + $ref: '#/components/schemas/ValidationError' + success: + type: boolean + + # ----------------------------------------------------------------------- + # NEW: UserResponsePage — suffix=Page, slotArray=content + # content items ref UserResponse (which is itself a generic instance → ApiResponse) + # Recursive expansion: Page> + # ----------------------------------------------------------------------- + + UserResponsePage: + type: object + description: | + Page whose content items are UserResponse (itself a generic instance). + Tests recursive return-type expansion: + UserResponsePage → Page> + required: [content, page] + properties: + content: + type: array + items: + $ref: '#/components/schemas/UserResponse' + page: + $ref: '#/components/schemas/PageMeta' + + # ----------------------------------------------------------------------- + # NEW: OrderDetails — non-generic model with a generic-instance property + # userResult: $ref UserResponse — should be substituted to ApiResponse + # pet: $ref Pet — should NOT be changed (not a generic instance) + # ----------------------------------------------------------------------- + + OrderDetails: + type: object + description: | + A non-generic model whose userResult property references a generic instance. + Tests property-level substitution: userResult type → ApiResponse + while pet (a plain domain type) is left unchanged. + properties: + userResult: + $ref: '#/components/schemas/UserResponse' + pet: + $ref: '#/components/schemas/Pet' + orderId: + type: string + + # ----------------------------------------------------------------------- + # NEW: NotificationBatch — non-generic model with array-of-generic-instance property + # responses: array of $ref UserResponse — should become List> + # ----------------------------------------------------------------------- + + NotificationBatch: + type: object + description: | + A non-generic model with an array property of a generic-instance type. + Tests array property substitution: responses type → List> + properties: + responses: + type: array + items: + $ref: '#/components/schemas/UserResponse' + batchId: + type: string diff --git a/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml index dcfa3797c804..9c2e359e3ff1 100644 --- a/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml @@ -540,6 +540,96 @@ paths: type: array items: $ref: '#/components/schemas/Pet' + /pet/findWithSizeConstraintFromAllOfRef: + get: + tags: + - pet + summary: Find pets — size maximum resolved from allOf $ref (no inline maximum) + operationId: findPetsWithSizeConstraintFromAllOfRef + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + - name: size + in: query + schema: + allOf: + - $ref: '#/components/schemas/PageSizeWithMax' + - name: sort + in: query + schema: + type: string + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /pet/findWithDefaultFromAllOfRef: + get: + tags: + - pet + summary: Find pets — size default resolved from allOf $ref (no inline default) + operationId: findPetsWithDefaultFromAllOfRef + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + - name: size + in: query + schema: + allOf: + - $ref: '#/components/schemas/PageSizeWithDefault' + - name: sort + in: query + schema: + type: string + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /pet/findWithMinSizeConstraintFromAllOfRef: + get: + tags: + - pet + summary: Find pets — size minimum resolved from allOf $ref (no inline minimum) + operationId: findPetsWithMinSizeConstraintFromAllOfRef + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + - name: size + in: query + schema: + allOf: + - $ref: '#/components/schemas/PageSizeWithMin' + - name: sort + in: query + schema: + type: string + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' # ---- openApiNullable / 4-state required×nullable test cases ---- /nullable/check-required-only: @@ -627,6 +717,18 @@ components: - "id,desc" - "createdAt,asc" - "createdAt,desc" + PageSizeWithMax: + type: integer + format: int32 + maximum: 75 + PageSizeWithDefault: + type: integer + format: int32 + default: 7 + PageSizeWithMin: + type: integer + format: int32 + minimum: 5 Pet: type: object required: diff --git a/samples/client/petstore/spring-cloud-tags/src/main/java/org/openapitools/api/PetController.java b/samples/client/petstore/spring-cloud-tags/src/main/java/org/openapitools/api/PetController.java index 01a1ef7cd935..78cd3c0d5fec 100644 --- a/samples/client/petstore/spring-cloud-tags/src/main/java/org/openapitools/api/PetController.java +++ b/samples/client/petstore/spring-cloud-tags/src/main/java/org/openapitools/api/PetController.java @@ -7,8 +7,6 @@ import org.openapitools.model.ModelApiResponse; import org.springframework.lang.Nullable; -import org.springframework.data.domain.Pageable; -import org.springdoc.core.annotations.ParameterObject; import org.openapitools.model.Pet; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; @@ -127,8 +125,7 @@ ResponseEntity deletePet( produces = { "application/json", "application/xml" } ) ResponseEntity> findPetsByStatus( - @NotNull @Parameter(name = "status", description = "Status values that need to be considered for filter", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "status", required = true) List status, - @ParameterObject final Pageable pageable + @NotNull @Parameter(name = "status", description = "Status values that need to be considered for filter", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "status", required = true) List status ); @@ -166,8 +163,7 @@ ResponseEntity> findPetsByStatus( produces = { "application/json", "application/xml" } ) ResponseEntity> findPetsByTags( - @NotNull @Parameter(name = "tags", description = "Tags to filter by", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "tags", required = true) List tags, - @ParameterObject final Pageable pageable + @NotNull @Parameter(name = "tags", description = "Tags to filter by", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "tags", required = true) List tags ); diff --git a/samples/openapi3/client/petstore/spring-cloud-spring-pageable/src/main/java/org/openapitools/api/PetApi.java b/samples/openapi3/client/petstore/spring-cloud-spring-pageable/src/main/java/org/openapitools/api/PetApi.java index 320effcb2eae..6d015c8e2890 100644 --- a/samples/openapi3/client/petstore/spring-cloud-spring-pageable/src/main/java/org/openapitools/api/PetApi.java +++ b/samples/openapi3/client/petstore/spring-cloud-spring-pageable/src/main/java/org/openapitools/api/PetApi.java @@ -7,8 +7,6 @@ import org.openapitools.model.ModelApiResponse; import org.springframework.lang.Nullable; -import org.springframework.data.domain.Pageable; -import org.springdoc.core.annotations.ParameterObject; import org.openapitools.model.Pet; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; @@ -129,8 +127,7 @@ ResponseEntity deletePet( @org.springframework.validation.annotation.Validated @org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')") ResponseEntity> findPetsByStatus( - @NotNull @Parameter(name = "status", description = "Status values that need to be considered for filter", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "status", required = true) List status, - @ParameterObject final Pageable pageable + @NotNull @Parameter(name = "status", description = "Status values that need to be considered for filter", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "status", required = true) List status ); @@ -140,6 +137,9 @@ ResponseEntity> findPetsByStatus( * Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. * * @param tags Tags to filter by (required) + * @param size2 The number of items to return per page. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used. (required) + * @param page The page to return, starting with page 0. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used. (required) + * @param sort The sorting to apply to the Pageable object. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used. (required) * @param size A test HeaderParam for issue #8315 - must NOT be removed when x-spring-paginated:true is used. (optional) * @return successful operation (status code 200) * or Invalid tag value (status code 400) @@ -170,8 +170,10 @@ ResponseEntity> findPetsByStatus( ) ResponseEntity> findPetsByTags( @NotNull @Parameter(name = "tags", description = "Tags to filter by", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "tags", required = true) List tags, - @Parameter(name = "size", description = "A test HeaderParam for issue #8315 - must NOT be removed when x-spring-paginated:true is used.", in = ParameterIn.HEADER) @RequestHeader(value = "size", required = false) @Nullable String size, - @ParameterObject final Pageable pageable + @NotNull @Min(value = 1) @Parameter(name = "size", description = "The number of items to return per page. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "size", required = true, defaultValue = "20") Integer size2, + @NotNull @Min(value = 0) @Parameter(name = "page", description = "The page to return, starting with page 0. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "page", required = true, defaultValue = "0") Integer page, + @NotNull @Parameter(name = "sort", description = "The sorting to apply to the Pageable object. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "sort", required = true, defaultValue = "id,asc") String sort, + @Parameter(name = "size", description = "A test HeaderParam for issue #8315 - must NOT be removed when x-spring-paginated:true is used.", in = ParameterIn.HEADER) @RequestHeader(value = "size", required = false) @Nullable String size ); @@ -217,6 +219,9 @@ ResponseEntity getPetById( * GET /pet/all : List all pets * Returns all pets with pagination support * + * @param page The page number to return. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used. (optional, default to 0) + * @param size The number of items to return per page. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used. (optional, default to 20) + * @param sort The sort order. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used. (optional) * @return successful operation (status code 200) * or Invalid status value (status code 400) */ @@ -243,7 +248,9 @@ ResponseEntity getPetById( ) @org.springframework.validation.annotation.Validated ResponseEntity> listAllPets( - @ParameterObject final Pageable pageable + @Parameter(name = "page", description = "The page number to return. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", in = ParameterIn.QUERY) @Valid @RequestParam(value = "page", required = false, defaultValue = "0") Integer page, + @Parameter(name = "size", description = "The number of items to return per page. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", in = ParameterIn.QUERY) @Valid @RequestParam(value = "size", required = false, defaultValue = "20") Integer size, + @Parameter(name = "sort", description = "The sort order. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", in = ParameterIn.QUERY) @Valid @RequestParam(value = "sort", required = false) @Nullable String sort ); diff --git a/samples/server/petstore/kotlin-springboot-generics/.openapi-generator-ignore b/samples/server/petstore/kotlin-springboot-generics/.openapi-generator-ignore new file mode 100644 index 000000000000..7484ee590a38 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/.openapi-generator-ignore @@ -0,0 +1,23 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/samples/server/petstore/kotlin-springboot-generics/.openapi-generator/FILES b/samples/server/petstore/kotlin-springboot-generics/.openapi-generator/FILES new file mode 100644 index 000000000000..a571abd9d2ea --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/.openapi-generator/FILES @@ -0,0 +1,31 @@ +README.md +build.gradle.kts +gradle/wrapper/gradle-wrapper.jar +gradle/wrapper/gradle-wrapper.properties +gradlew +gradlew.bat +pom.xml +settings.gradle +src/main/kotlin/org/openapitools/api/ApiUtil.kt +src/main/kotlin/org/openapitools/api/Exceptions.kt +src/main/kotlin/org/openapitools/api/ObservabilityApi.kt +src/main/kotlin/org/openapitools/api/PageApi.kt +src/main/kotlin/org/openapitools/api/ResponseApi.kt +src/main/kotlin/org/openapitools/api/ResultApi.kt +src/main/kotlin/org/openapitools/api/SearchApi.kt +src/main/kotlin/org/openapitools/api/VendorApi.kt +src/main/kotlin/org/openapitools/configuration/ApiResponse.kt +src/main/kotlin/org/openapitools/configuration/Result.kt +src/main/kotlin/org/openapitools/model/LogEntry.kt +src/main/kotlin/org/openapitools/model/LogEntryData.kt +src/main/kotlin/org/openapitools/model/MetricsEntry.kt +src/main/kotlin/org/openapitools/model/MetricsEntryData.kt +src/main/kotlin/org/openapitools/model/NotificationBatch.kt +src/main/kotlin/org/openapitools/model/Order.kt +src/main/kotlin/org/openapitools/model/OrderDetails.kt +src/main/kotlin/org/openapitools/model/PageMeta.kt +src/main/kotlin/org/openapitools/model/PaymentError.kt +src/main/kotlin/org/openapitools/model/Pet.kt +src/main/kotlin/org/openapitools/model/SearchResult.kt +src/main/kotlin/org/openapitools/model/User.kt +src/main/kotlin/org/openapitools/model/ValidationError.kt diff --git a/samples/server/petstore/kotlin-springboot-generics/.openapi-generator/VERSION b/samples/server/petstore/kotlin-springboot-generics/.openapi-generator/VERSION new file mode 100644 index 000000000000..ca7bf6e46889 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.23.0-SNAPSHOT diff --git a/samples/server/petstore/kotlin-springboot-generics/README.md b/samples/server/petstore/kotlin-springboot-generics/README.md new file mode 100644 index 000000000000..7dea2c683537 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/README.md @@ -0,0 +1,21 @@ +# openAPIPetstoreGenericsTest + +This Kotlin based [Spring Boot](https://spring.io/projects/spring-boot) application has been generated using the [OpenAPI Generator](https://github.com/OpenAPITools/openapi-generator). + +## Getting Started + +This document assumes you have either maven or gradle available, either via the wrapper or otherwise. This does not come with a gradle / maven wrapper checked in. + +By default a [`pom.xml`](pom.xml) file will be generated. If you specified `gradleBuildFile=true` when generating this project, a `build.gradle.kts` will also be generated. Note this uses [Gradle Kotlin DSL](https://github.com/gradle/kotlin-dsl). + +To build the project using maven, run: +```bash +mvn package && java -jar target/openapi-spring-1.0.0.jar +``` + +To build the project using gradle, run: +```bash +gradle build && java -jar build/libs/openapi-spring-1.0.0.jar +``` + +If all builds successfully, the server should run on [http://localhost:8080/](http://localhost:8080/) diff --git a/samples/server/petstore/kotlin-springboot-generics/build.gradle.kts b/samples/server/petstore/kotlin-springboot-generics/build.gradle.kts new file mode 100644 index 000000000000..30b6a49c9bf6 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/build.gradle.kts @@ -0,0 +1,49 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +group = "org.openapitools" +version = "1.0.0" +java.sourceCompatibility = JavaVersion.VERSION_17 + +repositories { + mavenCentral() + maven { url = uri("https://repo.spring.io/milestone") } +} + +tasks.withType { + kotlinOptions.jvmTarget = "17" +} + +tasks.bootJar { + enabled = false +} + +plugins { + val kotlinVersion = "1.9.25" + id("org.jetbrains.kotlin.jvm") version kotlinVersion + id("org.jetbrains.kotlin.plugin.jpa") version kotlinVersion + id("org.jetbrains.kotlin.plugin.spring") version kotlinVersion + id("org.springframework.boot") version "3.3.13" + id("io.spring.dependency-management") version "1.1.7" +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.springframework.boot:spring-boot-starter-web") + + implementation("com.google.code.findbugs:jsr305:3.0.2") + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml") + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springframework.data:spring-data-commons") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("jakarta.validation:jakarta.validation-api") + + implementation("jakarta.annotation:jakarta.annotation-api:2.1.0") + + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") { + exclude(module = "junit") + } +} diff --git a/samples/server/petstore/kotlin-springboot-generics/gradle/wrapper/gradle-wrapper.jar b/samples/server/petstore/kotlin-springboot-generics/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000000..e6441136f3d4 Binary files /dev/null and b/samples/server/petstore/kotlin-springboot-generics/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/server/petstore/kotlin-springboot-generics/gradle/wrapper/gradle-wrapper.properties b/samples/server/petstore/kotlin-springboot-generics/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..80187ac30432 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/samples/server/petstore/kotlin-springboot-generics/gradlew b/samples/server/petstore/kotlin-springboot-generics/gradlew new file mode 100644 index 000000000000..9d0ce634cb11 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while +APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path +[ -h "$app_path" ] +do +ls=$( ls -ld "$app_path" ) +link=${ls#*' -> '} +case $link in #( +/*) app_path=$link ;; #( +*) app_path=$APP_HOME$link ;; +esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { +echo "$*" +} >&2 + +die () { +echo +echo "$*" +echo +exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( +CYGWIN* ) cygwin=true ;; #( +Darwin* ) darwin=true ;; #( +MSYS* | MINGW* ) msys=true ;; #( +NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then +if [ -x "$JAVA_HOME/jre/sh/java" ] ; then +# IBM's JDK on AIX uses strange locations for the executables +JAVACMD=$JAVA_HOME/jre/sh/java +else +JAVACMD=$JAVA_HOME/bin/java +fi +if [ ! -x "$JAVACMD" ] ; then +die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi +else +JAVACMD=java +if ! command -v java >/dev/null 2>&1 +then +die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then +case $MAX_FD in #( +max*) +# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. +# shellcheck disable=SC2039,SC3045 +MAX_FD=$( ulimit -H -n ) || +warn "Could not query maximum file descriptor limit" +esac +case $MAX_FD in #( +'' | soft) :;; #( +*) +# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. +# shellcheck disable=SC2039,SC3045 +ulimit -n "$MAX_FD" || +warn "Could not set maximum file descriptor limit to $MAX_FD" +esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then +APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) +CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + +JAVACMD=$( cygpath --unix "$JAVACMD" ) + +# Now convert the arguments - kludge to limit ourselves to /bin/sh +for arg do +if +case $arg in #( +-*) false ;; # don't mess with options #( +/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath +[ -e "$t" ] ;; #( +*) false ;; +esac +then +arg=$( cygpath --path --ignore --mixed "$arg" ) +fi +# Roll the args list around exactly as many times as the number of +# args, so each arg winds up back in the position where it started, but +# possibly modified. +# +# NB: a `for` loop captures its iteration list before it begins, so +# changing the positional parameters here affects neither the number of +# iterations, nor the values presented in `arg`. +shift # remove old arg +set -- "$@" "$arg" # push replacement arg +done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ +"-Dorg.gradle.appname=$APP_BASE_NAME" \ +-classpath "$CLASSPATH" \ +org.gradle.wrapper.GradleWrapperMain \ +"$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then +die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( +printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | +xargs -n1 | +sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | +tr '\n' ' ' +)" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/samples/server/petstore/kotlin-springboot-generics/gradlew.bat b/samples/server/petstore/kotlin-springboot-generics/gradlew.bat new file mode 100644 index 000000000000..25da30dbdeee --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/samples/server/petstore/kotlin-springboot-generics/pom.xml b/samples/server/petstore/kotlin-springboot-generics/pom.xml new file mode 100644 index 000000000000..c83b8a04b7ef --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/pom.xml @@ -0,0 +1,158 @@ + + 4.0.0 + org.openapitools + openapi-spring + jar + openapi-spring + 1.0.0 + + 3.0.2 + 2.1.0 + 1.9.25 + + 1.9.25 + UTF-8 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.13 + + + + repository.spring.milestone + Spring Milestone Repository + https://repo.spring.io/milestone + + + + + spring-milestones + https://repo.spring.io/milestone + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/test/kotlin + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar + + + + + + kotlin-maven-plugin + org.jetbrains.kotlin + ${kotlin.version} + + + spring + + 17 + + + + compile + compile + + compile + + + + test-compile + test-compile + + test-compile + + + + + + org.jetbrains.kotlin + kotlin-maven-allopen + ${kotlin.version} + + + + + + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.data + spring-data-commons + + + + + + + com.google.code.findbugs + jsr305 + ${findbugs-jsr305.version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.fasterxml.jackson.module + jackson-module-kotlin + + + + jakarta.validation + jakarta.validation-api + + + org.springframework.boot + spring-boot-starter-validation + + + + jakarta.annotation + jakarta.annotation-api + ${jakarta-annotation.version} + provided + + + org.springframework.boot + spring-boot-starter-test + test + + + org.jetbrains.kotlin + kotlin-test-junit5 + ${kotlin-test-junit5.version} + test + + + diff --git a/samples/server/petstore/kotlin-springboot-generics/settings.gradle b/samples/server/petstore/kotlin-springboot-generics/settings.gradle new file mode 100644 index 000000000000..14844905cd40 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/settings.gradle @@ -0,0 +1,15 @@ +pluginManagement { + repositories { + maven { url = uri("https://repo.spring.io/snapshot") } + maven { url = uri("https://repo.spring.io/milestone") } + gradlePluginPortal() + } + resolutionStrategy { + eachPlugin { + if (requested.id.id == "org.springframework.boot") { + useModule("org.springframework.boot:spring-boot-gradle-plugin:${requested.version}") + } + } + } +} +rootProject.name = "openapi-spring" diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ApiUtil.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ApiUtil.kt new file mode 100644 index 000000000000..03344e13b474 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ApiUtil.kt @@ -0,0 +1,19 @@ +package org.openapitools.api + +import org.springframework.web.context.request.NativeWebRequest + +import jakarta.servlet.http.HttpServletResponse +import java.io.IOException + +object ApiUtil { + fun setExampleResponse(req: NativeWebRequest, contentType: String, example: String) { + try { + val res = req.getNativeResponse(HttpServletResponse::class.java) + res?.characterEncoding = "UTF-8" + res?.addHeader("Content-Type", contentType) + res?.writer?.print(example) + } catch (e: IOException) { + throw RuntimeException(e) + } + } +} diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/Exceptions.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/Exceptions.kt new file mode 100644 index 000000000000..1bd78f54576a --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/Exceptions.kt @@ -0,0 +1,30 @@ +package org.openapitools.api + +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import jakarta.servlet.http.HttpServletResponse +import jakarta.validation.ConstraintViolationException + +// TODO Extend ApiException for custom exception handling, e.g. the below NotFound exception +sealed class ApiException(msg: String, val code: Int) : Exception(msg) + +class NotFoundException(msg: String, code: Int = HttpStatus.NOT_FOUND.value()) : ApiException(msg, code) + +@Configuration("org.openapitools.api.DefaultExceptionHandler") +@ControllerAdvice +class DefaultExceptionHandler { + + @ExceptionHandler(value = [ApiException::class]) + fun onApiException(ex: ApiException, response: HttpServletResponse): Unit = + response.sendError(ex.code, ex.message) + + @ExceptionHandler(value = [NotImplementedError::class]) + fun onNotImplemented(ex: NotImplementedError, response: HttpServletResponse): Unit = + response.sendError(HttpStatus.NOT_IMPLEMENTED.value()) + + @ExceptionHandler(value = [ConstraintViolationException::class]) + fun onConstraintViolation(ex: ConstraintViolationException, response: HttpServletResponse): Unit = + response.sendError(HttpStatus.BAD_REQUEST.value(), ex.constraintViolations.joinToString(", ") { it.message }) +} diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ObservabilityApi.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ObservabilityApi.kt new file mode 100644 index 000000000000..f6f9b994a8e1 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ObservabilityApi.kt @@ -0,0 +1,50 @@ +/** + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.23.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. +*/ +package org.openapitools.api + +import org.openapitools.model.LogEntry +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity + +import org.springframework.web.bind.annotation.* +import org.springframework.validation.annotation.Validated +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.beans.factory.annotation.Autowired + +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +import kotlin.collections.List +import kotlin.collections.Map + +@RestController +@Validated +@RequestMapping("\${api.base-path:}") +interface ObservabilityApi { + + + @RequestMapping( + method = [RequestMethod.GET], + // "/logs/latest" + value = [PATH_GET_LATEST_LOG_ENTRY], + produces = ["application/json"] + ) + fun getLatestLogEntry(): ResponseEntity + + companion object { + //for your own safety never directly reuse these path definitions in tests + const val BASE_PATH: String = "" + const val PATH_GET_LATEST_LOG_ENTRY: String = "/logs/latest" + } +} diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/PageApi.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/PageApi.kt new file mode 100644 index 000000000000..050478b44c1d --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/PageApi.kt @@ -0,0 +1,79 @@ +/** + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.23.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. +*/ +package org.openapitools.api + +import org.openapitools.configuration.ApiResponse +import org.springframework.data.domain.Page +import org.openapitools.model.Pet +import org.openapitools.model.User +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity + +import org.springframework.web.bind.annotation.* +import org.springframework.validation.annotation.Validated +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.beans.factory.annotation.Autowired + +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +import kotlin.collections.List +import kotlin.collections.Map + +@RestController +@Validated +@RequestMapping("\${api.base-path:}") +interface PageApi { + + + @RequestMapping( + method = [RequestMethod.GET], + // "/pets" + value = [PATH_LIST_PETS], + produces = ["application/json"] + ) + fun listPets( + @Valid @RequestParam(value = "page", required = false, defaultValue = "0") page: kotlin.Int, + @Valid @RequestParam(value = "size", required = false, defaultValue = "20") size: kotlin.Int + ): ResponseEntity> + + + @RequestMapping( + method = [RequestMethod.GET], + // "/users/responses/page" + value = [PATH_LIST_USER_RESPONSES], + produces = ["application/json"] + ) + fun listUserResponses(): ResponseEntity>> + + + @RequestMapping( + method = [RequestMethod.GET], + // "/users" + value = [PATH_LIST_USERS], + produces = ["application/json"] + ) + fun listUsers( + @Valid @RequestParam(value = "page", required = false, defaultValue = "0") page: kotlin.Int, + @Valid @RequestParam(value = "size", required = false, defaultValue = "20") size: kotlin.Int + ): ResponseEntity> + + companion object { + //for your own safety never directly reuse these path definitions in tests + const val BASE_PATH: String = "" + const val PATH_LIST_PETS: String = "/pets" + const val PATH_LIST_USER_RESPONSES: String = "/users/responses/page" + const val PATH_LIST_USERS: String = "/users" + } +} diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ResponseApi.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ResponseApi.kt new file mode 100644 index 000000000000..5e2ae58d9e5f --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ResponseApi.kt @@ -0,0 +1,103 @@ +/** + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.23.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. +*/ +package org.openapitools.api + +import org.openapitools.configuration.ApiResponse +import org.openapitools.model.NotificationBatch +import org.openapitools.model.Order +import org.openapitools.model.OrderDetails +import org.openapitools.model.Pet +import org.openapitools.model.User +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity + +import org.springframework.web.bind.annotation.* +import org.springframework.validation.annotation.Validated +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.beans.factory.annotation.Autowired + +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +import kotlin.collections.List +import kotlin.collections.Map + +@RestController +@Validated +@RequestMapping("\${api.base-path:}") +interface ResponseApi { + + + @RequestMapping( + method = [RequestMethod.GET], + // "/notifications" + value = [PATH_GET_NOTIFICATION_BATCH], + produces = ["application/json"] + ) + fun getNotificationBatch(): ResponseEntity + + + @RequestMapping( + method = [RequestMethod.GET], + // "/orders/{id}/details" + value = [PATH_GET_ORDER_DETAILS], + produces = ["application/json"] + ) + fun getOrderDetails( + @PathVariable("id") id: kotlin.String + ): ResponseEntity + + + @RequestMapping( + method = [RequestMethod.GET], + // "/orders/{id}/response" + value = [PATH_GET_ORDER_RESPONSE], + produces = ["application/json"] + ) + fun getOrderResponse( + @PathVariable("id") id: kotlin.String + ): ResponseEntity> + + + @RequestMapping( + method = [RequestMethod.GET], + // "/pets/{id}/response" + value = [PATH_GET_PET_RESPONSE], + produces = ["application/json"] + ) + fun getPetResponse( + @PathVariable("id") id: kotlin.String + ): ResponseEntity> + + + @RequestMapping( + method = [RequestMethod.GET], + // "/users/{id}/response" + value = [PATH_GET_USER_RESPONSE], + produces = ["application/json"] + ) + fun getUserResponse( + @PathVariable("id") id: kotlin.String + ): ResponseEntity> + + companion object { + //for your own safety never directly reuse these path definitions in tests + const val BASE_PATH: String = "" + const val PATH_GET_NOTIFICATION_BATCH: String = "/notifications" + const val PATH_GET_ORDER_DETAILS: String = "/orders/{id}/details" + const val PATH_GET_ORDER_RESPONSE: String = "/orders/{id}/response" + const val PATH_GET_PET_RESPONSE: String = "/pets/{id}/response" + const val PATH_GET_USER_RESPONSE: String = "/users/{id}/response" + } +} diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ResultApi.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ResultApi.kt new file mode 100644 index 000000000000..a0b70376bbae --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ResultApi.kt @@ -0,0 +1,81 @@ +/** + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.23.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. +*/ +package org.openapitools.api + +import org.openapitools.configuration.ApiResponse +import org.openapitools.model.Order +import org.openapitools.model.PaymentError +import org.openapitools.configuration.Result +import org.openapitools.model.User +import org.openapitools.model.ValidationError +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity + +import org.springframework.web.bind.annotation.* +import org.springframework.validation.annotation.Validated +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.beans.factory.annotation.Autowired + +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +import kotlin.collections.List +import kotlin.collections.Map + +@RestController +@Validated +@RequestMapping("\${api.base-path:}") +interface ResultApi { + + + @RequestMapping( + method = [RequestMethod.GET], + // "/orders/{id}/error-result" + value = [PATH_GET_ORDER_ERROR_RESULT], + produces = ["application/json"] + ) + fun getOrderErrorResult( + @PathVariable("id") id: kotlin.String + ): ResponseEntity> + + + @RequestMapping( + method = [RequestMethod.GET], + // "/users/{id}/error-result" + value = [PATH_GET_USER_ERROR_RESULT], + produces = ["application/json"] + ) + fun getUserErrorResult( + @PathVariable("id") id: kotlin.String + ): ResponseEntity> + + + @RequestMapping( + method = [RequestMethod.GET], + // "/users/{id}/response-error-result" + value = [PATH_GET_USER_RESPONSE_ERROR_RESULT], + produces = ["application/json"] + ) + fun getUserResponseErrorResult( + @PathVariable("id") id: kotlin.String + ): ResponseEntity, ValidationError>> + + companion object { + //for your own safety never directly reuse these path definitions in tests + const val BASE_PATH: String = "" + const val PATH_GET_ORDER_ERROR_RESULT: String = "/orders/{id}/error-result" + const val PATH_GET_USER_ERROR_RESULT: String = "/users/{id}/error-result" + const val PATH_GET_USER_RESPONSE_ERROR_RESULT: String = "/users/{id}/response-error-result" + } +} diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/SearchApi.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/SearchApi.kt new file mode 100644 index 000000000000..9cda0e6ad083 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/SearchApi.kt @@ -0,0 +1,52 @@ +/** + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.23.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. +*/ +package org.openapitools.api + +import org.openapitools.model.SearchResult +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity + +import org.springframework.web.bind.annotation.* +import org.springframework.validation.annotation.Validated +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.beans.factory.annotation.Autowired + +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +import kotlin.collections.List +import kotlin.collections.Map + +@RestController +@Validated +@RequestMapping("\${api.base-path:}") +interface SearchApi { + + + @RequestMapping( + method = [RequestMethod.GET], + // "/search" + value = [PATH_SEARCH], + produces = ["application/json"] + ) + fun search( + @Valid @RequestParam(value = "q", required = false) q: kotlin.String? + ): ResponseEntity + + companion object { + //for your own safety never directly reuse these path definitions in tests + const val BASE_PATH: String = "" + const val PATH_SEARCH: String = "/search" + } +} diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/VendorApi.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/VendorApi.kt new file mode 100644 index 000000000000..514dfc3f2e6a --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/VendorApi.kt @@ -0,0 +1,51 @@ +/** + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.23.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. +*/ +package org.openapitools.api + +import org.openapitools.model.User +import com.example.generic.VendorResult +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity + +import org.springframework.web.bind.annotation.* +import org.springframework.validation.annotation.Validated +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.beans.factory.annotation.Autowired + +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +import kotlin.collections.List +import kotlin.collections.Map + +@RestController +@Validated +@RequestMapping("\${api.base-path:}") +interface VendorApi { + + + @RequestMapping( + method = [RequestMethod.GET], + // "/vendor/user-result" + value = [PATH_GET_VENDOR_USER_RESULT], + produces = ["application/json"] + ) + fun getVendorUserResult(): ResponseEntity> + + companion object { + //for your own safety never directly reuse these path definitions in tests + const val BASE_PATH: String = "" + const val PATH_GET_VENDOR_USER_RESULT: String = "/vendor/user-result" + } +} diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/configuration/ApiResponse.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/configuration/ApiResponse.kt new file mode 100644 index 000000000000..cab93a54ca91 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/configuration/ApiResponse.kt @@ -0,0 +1,13 @@ +package org.openapitools.configuration +/** + * Generic class generated by openapi-generator from schema pattern 'ApiResponse'. + * Type parameters correspond to the varying domain types. + * + * To use your own class instead, supply a fully-qualified class name via + * `importMappings.ApiResponse` in the generator config. + */ +data class ApiResponse( + val data: T, + val status: String, + val message: String? = null, +) diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/configuration/Result.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/configuration/Result.kt new file mode 100644 index 000000000000..89f037865303 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/configuration/Result.kt @@ -0,0 +1,13 @@ +package org.openapitools.configuration +/** + * Generic class generated by openapi-generator from schema pattern 'Result'. + * Type parameters correspond to the varying domain types. + * + * To use your own class instead, supply a fully-qualified class name via + * `importMappings.Result` in the generator config. + */ +data class Result( + val data: T, + val error: E? = null, + val success: Boolean? = null, +) diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/LogEntry.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/LogEntry.kt new file mode 100644 index 000000000000..b42167d47af1 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/LogEntry.kt @@ -0,0 +1,41 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import org.openapitools.model.LogEntryData +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +/** + * Log entry wrapper. Same structure as MetricsEntry except 'data' points to LogEntryData. These two form a Tier 3 cluster suggestion. + * @param `data` + * @param severity + * @param timestamp + */ +data class LogEntry( + + @field:Valid + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("data") val `data`: LogEntryData? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("severity") val severity: kotlin.String? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("timestamp") val timestamp: java.time.OffsetDateTime? = null +) : java.io.Serializable { + + companion object { + private const val serialVersionUID: kotlin.Long = 1 + } +} + diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/LogEntryData.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/LogEntryData.kt new file mode 100644 index 000000000000..986c9dce4708 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/LogEntryData.kt @@ -0,0 +1,39 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +/** + * Payload for a log entry + * @param level + * @param message + * @param source + */ +data class LogEntryData( + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("level") val level: kotlin.String? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("message") val message: kotlin.String? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("source") val source: kotlin.String? = null +) : java.io.Serializable { + + companion object { + private const val serialVersionUID: kotlin.Long = 1 + } +} + diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/MetricsEntry.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/MetricsEntry.kt new file mode 100644 index 000000000000..a93f14016db2 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/MetricsEntry.kt @@ -0,0 +1,41 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import org.openapitools.model.MetricsEntryData +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +/** + * Metrics entry wrapper. Same structure as LogEntry except 'data' points to MetricsEntryData. These two form a Tier 3 cluster suggestion. + * @param `data` + * @param severity + * @param timestamp + */ +data class MetricsEntry( + + @field:Valid + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("data") val `data`: MetricsEntryData? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("severity") val severity: kotlin.String? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("timestamp") val timestamp: java.time.OffsetDateTime? = null +) : java.io.Serializable { + + companion object { + private const val serialVersionUID: kotlin.Long = 1 + } +} + diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/MetricsEntryData.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/MetricsEntryData.kt new file mode 100644 index 000000000000..58a3e63a0ab7 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/MetricsEntryData.kt @@ -0,0 +1,39 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +/** + * Payload for a metrics entry + * @param metricName + * @param `value` + * @param unit + */ +data class MetricsEntryData( + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("metricName") val metricName: kotlin.String? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("value") val `value`: kotlin.Double? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("unit") val unit: kotlin.String? = null +) : java.io.Serializable { + + companion object { + private const val serialVersionUID: kotlin.Long = 1 + } +} + diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/NotificationBatch.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/NotificationBatch.kt new file mode 100644 index 000000000000..1fae17b9fda0 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/NotificationBatch.kt @@ -0,0 +1,38 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import org.openapitools.model.User +import org.openapitools.configuration.ApiResponse +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +/** + * A non-generic model with an array property of a generic-instance type. Tests array property substitution: responses type → List> + * @param responses + * @param batchId + */ +data class NotificationBatch( + + @field:Valid + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("responses") val responses: kotlin.collections.List>? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("batchId") val batchId: kotlin.String? = null +) : java.io.Serializable { + + companion object { + private const val serialVersionUID: kotlin.Long = 1 + } +} + diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/Order.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/Order.kt new file mode 100644 index 000000000000..a29b9e9acc10 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/Order.kt @@ -0,0 +1,38 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +/** + * + * @param orderId + * @param quantity + * @param totalPrice + */ +data class Order( + + @get:JsonProperty("orderId", required = true) val orderId: kotlin.String, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("quantity") val quantity: kotlin.Int? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("totalPrice") val totalPrice: kotlin.Double? = null +) : java.io.Serializable { + + companion object { + private const val serialVersionUID: kotlin.Long = 1 + } +} + diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/OrderDetails.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/OrderDetails.kt new file mode 100644 index 000000000000..78ddf9f2bac3 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/OrderDetails.kt @@ -0,0 +1,44 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import org.openapitools.model.Pet +import org.openapitools.model.User +import org.openapitools.configuration.ApiResponse +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +/** + * A non-generic model whose userResult property references a generic instance. Tests property-level substitution: userResult type → ApiResponse while pet (a plain domain type) is left unchanged. + * @param userResult + * @param pet + * @param orderId + */ +data class OrderDetails( + + @field:Valid + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("userResult") val userResult: ApiResponse? = null, + + @field:Valid + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("pet") val pet: Pet? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("orderId") val orderId: kotlin.String? = null +) : java.io.Serializable { + + companion object { + private const val serialVersionUID: kotlin.Long = 1 + } +} + diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/PageMeta.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/PageMeta.kt new file mode 100644 index 000000000000..6768a1885260 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/PageMeta.kt @@ -0,0 +1,37 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +/** + * Pagination metadata (inline) + * @param propertySize + * @param number + * @param totalElements + * @param totalPages + */ +data class PageMeta( + + @get:JsonProperty("size", required = true) val propertySize: kotlin.Long, + + @get:JsonProperty("number", required = true) val number: kotlin.Long, + + @get:JsonProperty("totalElements", required = true) val totalElements: kotlin.Long, + + @get:JsonProperty("totalPages", required = true) val totalPages: kotlin.Long +) : java.io.Serializable { + + companion object { + private const val serialVersionUID: kotlin.Long = 1 + } +} + diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/PaymentError.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/PaymentError.kt new file mode 100644 index 000000000000..7faed5b49ba0 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/PaymentError.kt @@ -0,0 +1,37 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +/** + * Payment processing error details + * @param reason Reason for payment failure + * @param amount Amount that failed to process + * @param retryable Whether the payment can be retried + */ +data class PaymentError( + + @get:JsonProperty("reason", required = true) val reason: kotlin.String, + + @get:JsonProperty("amount", required = true) val amount: kotlin.Double, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("retryable") val retryable: kotlin.Boolean? = null +) : java.io.Serializable { + + companion object { + private const val serialVersionUID: kotlin.Long = 1 + } +} + diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/Pet.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/Pet.kt new file mode 100644 index 000000000000..930bda1f2185 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/Pet.kt @@ -0,0 +1,38 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +/** + * + * @param name + * @param id + * @param species + */ +data class Pet( + + @get:JsonProperty("name", required = true) val name: kotlin.String, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("id") val id: kotlin.Long? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("species") val species: kotlin.String? = null +) : java.io.Serializable { + + companion object { + private const val serialVersionUID: kotlin.Long = 1 + } +} + diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/SearchResult.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/SearchResult.kt new file mode 100644 index 000000000000..2d667a48a2c8 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/SearchResult.kt @@ -0,0 +1,43 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +/** + * Search result — unique structure, not matched by any pattern + * @param query + * @param totalHits + * @param results + * @param facets + */ +data class SearchResult( + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("query") val query: kotlin.String? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("totalHits") val totalHits: kotlin.Long? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("results") val results: kotlin.collections.List? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("facets") val facets: kotlin.collections.Map? = null +) : java.io.Serializable { + + companion object { + private const val serialVersionUID: kotlin.Long = 1 + } +} + diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/User.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/User.kt new file mode 100644 index 000000000000..1a59f2849984 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/User.kt @@ -0,0 +1,39 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +/** + * + * @param id + * @param name + * @param email + */ +data class User( + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("id") val id: kotlin.String? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("name") val name: kotlin.String? = null, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("email") val email: kotlin.String? = null +) : java.io.Serializable { + + companion object { + private const val serialVersionUID: kotlin.Long = 1 + } +} + diff --git a/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/ValidationError.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/ValidationError.kt new file mode 100644 index 000000000000..7a1e6c6d008b --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/ValidationError.kt @@ -0,0 +1,37 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +/** + * Validation error details + * @param `field` Name of the field that failed validation + * @param message Human-readable validation error message + * @param code Machine-readable error code + */ +data class ValidationError( + + @get:JsonProperty("field", required = true) val `field`: kotlin.String, + + @get:JsonProperty("message", required = true) val message: kotlin.String, + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("code") val code: kotlin.String? = null +) : java.io.Serializable { + + companion object { + private const val serialVersionUID: kotlin.Long = 1 + } +} + diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApi.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApi.kt index 23cd662f699c..a3471ad6f48a 100644 --- a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApi.kt +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApi.kt @@ -104,6 +104,17 @@ interface PetApi { } + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithDefaultFromAllOfRef" + value = [PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF], + produces = ["application/json"] + ) + fun findPetsWithDefaultFromAllOfRef(pageable: Pageable): ResponseEntity> { + return ResponseEntity(HttpStatus.NOT_IMPLEMENTED) + } + + @RequestMapping( method = [RequestMethod.GET], // "/pet/findWithExternalParamRefArraySort" @@ -115,6 +126,17 @@ interface PetApi { } + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithMinSizeConstraintFromAllOfRef" + value = [PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF], + produces = ["application/json"] + ) + fun findPetsWithMinSizeConstraintFromAllOfRef(@ValidPageable(minSize = 5) pageable: Pageable): ResponseEntity> { + return ResponseEntity(HttpStatus.NOT_IMPLEMENTED) + } + + @RequestMapping( method = [RequestMethod.GET], // "/pet/findWithMixedSortDefaults" @@ -181,6 +203,17 @@ interface PetApi { } + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithSizeConstraintFromAllOfRef" + value = [PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF], + produces = ["application/json"] + ) + fun findPetsWithSizeConstraintFromAllOfRef(@ValidPageable(maxSize = 75) pageable: Pageable): ResponseEntity> { + return ResponseEntity(HttpStatus.NOT_IMPLEMENTED) + } + + @RequestMapping( method = [RequestMethod.GET], // "/pet/findWithSortDefaultAsc" @@ -235,13 +268,16 @@ interface PetApi { const val PATH_FIND_PETS_WITH_ALL_DEFAULTS: String = "/pet/findWithAllDefaults" const val PATH_FIND_PETS_WITH_ARRAY_SORT_ENUM: String = "/pet/findWithArraySortEnum" const val PATH_FIND_PETS_WITH_ARRAY_SORT_REF_ENUM: String = "/pet/findWithArraySortRefEnum" + const val PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF: String = "/pet/findWithDefaultFromAllOfRef" const val PATH_FIND_PETS_WITH_EXTERNAL_PARAM_REF_ARRAY_SORT: String = "/pet/findWithExternalParamRefArraySort" + const val PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF: String = "/pet/findWithMinSizeConstraintFromAllOfRef" const val PATH_FIND_PETS_WITH_MIXED_SORT_DEFAULTS: String = "/pet/findWithMixedSortDefaults" const val PATH_FIND_PETS_WITH_NON_EXPLODED_EXTERNAL_PARAM_REF_ARRAY_SORT: String = "/pet/findWithNonExplodedExternalParamRefArraySort" const val PATH_FIND_PETS_WITH_PAGE_AND_SIZE_CONSTRAINT: String = "/pet/findWithPageAndSizeConstraint" const val PATH_FIND_PETS_WITH_PAGE_SIZE_DEFAULTS_ONLY: String = "/pet/findWithPageSizeDefaultsOnly" const val PATH_FIND_PETS_WITH_REF_SORT: String = "/pet/findWithRefSort" const val PATH_FIND_PETS_WITH_SIZE_CONSTRAINT: String = "/pet/findWithSizeConstraint" + const val PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF: String = "/pet/findWithSizeConstraintFromAllOfRef" const val PATH_FIND_PETS_WITH_SORT_DEFAULT_ASC: String = "/pet/findWithSortDefaultAsc" const val PATH_FIND_PETS_WITH_SORT_DEFAULT_ONLY: String = "/pet/findWithSortDefaultOnly" const val PATH_FIND_PETS_WITH_SORT_ENUM: String = "/pet/findByStatusWithSort" diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidPageable.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidPageable.kt index 671e682ec6fe..095e3ba8fac5 100644 --- a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidPageable.kt +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidPageable.kt @@ -7,12 +7,14 @@ import jakarta.validation.Payload import org.springframework.data.domain.Pageable /** - * Validates that the page number and page size in the annotated [Pageable] parameter do not - * exceed their configured maximums. + * Validates that the page number and page size in the annotated [Pageable] parameter are within + * their configured bounds. * * Apply directly on a `pageable: Pageable` parameter. Each attribute is independently optional: * - [maxSize] — when set (>= 0), validates `pageable.pageSize <= maxSize` * - [maxPage] — when set (>= 0), validates `pageable.pageNumber <= maxPage` + * - [minSize] — when set (>= 0), validates `pageable.pageSize >= minSize` + * - [minPage] — when set (>= 0), validates `pageable.pageNumber >= minPage` * * Use [NO_LIMIT] (= -1, the default) to leave an attribute unconstrained. * @@ -21,6 +23,8 @@ import org.springframework.data.domain.Pageable * * @property maxSize Maximum allowed page size, or [NO_LIMIT] if unconstrained * @property maxPage Maximum allowed page number (0-based), or [NO_LIMIT] if unconstrained + * @property minSize Minimum allowed page size, or [NO_LIMIT] if unconstrained + * @property minPage Minimum allowed page number (0-based), or [NO_LIMIT] if unconstrained * @property groups Validation groups (optional) * @property payload Additional payload (optional) * @property message Validation error message (default: "Invalid page request") @@ -32,6 +36,8 @@ import org.springframework.data.domain.Pageable annotation class ValidPageable( val maxSize: Int = ValidPageable.NO_LIMIT, val maxPage: Int = ValidPageable.NO_LIMIT, + val minSize: Int = ValidPageable.NO_LIMIT, + val minPage: Int = ValidPageable.NO_LIMIT, val groups: Array> = [], val payload: Array> = [], val message: String = "Invalid page request" @@ -45,10 +51,14 @@ class PageableConstraintValidator : ConstraintValidator private var maxSize = ValidPageable.NO_LIMIT private var maxPage = ValidPageable.NO_LIMIT + private var minSize = ValidPageable.NO_LIMIT + private var minPage = ValidPageable.NO_LIMIT override fun initialize(constraintAnnotation: ValidPageable) { maxSize = constraintAnnotation.maxSize maxPage = constraintAnnotation.maxPage + minSize = constraintAnnotation.minSize + minPage = constraintAnnotation.minPage } override fun isValid(pageable: Pageable?, context: ConstraintValidatorContext): Boolean { @@ -76,6 +86,24 @@ class PageableConstraintValidator : ConstraintValidator valid = false } + if (minSize >= 0 && pageable.pageSize < minSize) { + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate}: page size ${pageable.pageSize} is below minimum $minSize" + ) + .addPropertyNode("size") + .addConstraintViolation() + valid = false + } + + if (minPage >= 0 && pageable.pageNumber < minPage) { + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate}: page number ${pageable.pageNumber} is below minimum $minPage" + ) + .addPropertyNode("page") + .addConstraintViolation() + valid = false + } + return valid } } diff --git a/samples/server/petstore/springboot-generics/.openapi-generator-ignore b/samples/server/petstore/springboot-generics/.openapi-generator-ignore new file mode 100644 index 000000000000..7484ee590a38 --- /dev/null +++ b/samples/server/petstore/springboot-generics/.openapi-generator-ignore @@ -0,0 +1,23 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/samples/server/petstore/springboot-generics/.openapi-generator/FILES b/samples/server/petstore/springboot-generics/.openapi-generator/FILES new file mode 100644 index 000000000000..1025fb014f46 --- /dev/null +++ b/samples/server/petstore/springboot-generics/.openapi-generator/FILES @@ -0,0 +1,24 @@ +README.md +pom.xml +src/main/java/org/openapitools/api/ApiUtil.java +src/main/java/org/openapitools/api/ObservabilityApi.java +src/main/java/org/openapitools/api/PageApi.java +src/main/java/org/openapitools/api/ResponseApi.java +src/main/java/org/openapitools/api/ResultApi.java +src/main/java/org/openapitools/api/SearchApi.java +src/main/java/org/openapitools/api/VendorApi.java +src/main/java/org/openapitools/configuration/ApiResponse.java +src/main/java/org/openapitools/configuration/Result.java +src/main/java/org/openapitools/model/LogEntry.java +src/main/java/org/openapitools/model/LogEntryData.java +src/main/java/org/openapitools/model/MetricsEntry.java +src/main/java/org/openapitools/model/MetricsEntryData.java +src/main/java/org/openapitools/model/NotificationBatch.java +src/main/java/org/openapitools/model/Order.java +src/main/java/org/openapitools/model/OrderDetails.java +src/main/java/org/openapitools/model/PageMeta.java +src/main/java/org/openapitools/model/PaymentError.java +src/main/java/org/openapitools/model/Pet.java +src/main/java/org/openapitools/model/SearchResult.java +src/main/java/org/openapitools/model/User.java +src/main/java/org/openapitools/model/ValidationError.java diff --git a/samples/server/petstore/springboot-generics/.openapi-generator/VERSION b/samples/server/petstore/springboot-generics/.openapi-generator/VERSION new file mode 100644 index 000000000000..ca7bf6e46889 --- /dev/null +++ b/samples/server/petstore/springboot-generics/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.23.0-SNAPSHOT diff --git a/samples/server/petstore/springboot-generics/README.md b/samples/server/petstore/springboot-generics/README.md new file mode 100644 index 000000000000..d43a1de307df --- /dev/null +++ b/samples/server/petstore/springboot-generics/README.md @@ -0,0 +1,27 @@ + +# OpenAPI generated API stub + +Spring Framework stub + + +## Overview +This code was generated by the [OpenAPI Generator](https://openapi-generator.tech) project. +By using the [OpenAPI-Spec](https://openapis.org), you can easily generate an API stub. +This is an example of building API stub interfaces in Java using the Spring framework. + +The stubs generated can be used in your existing Spring-MVC or Spring-Boot application to create controller endpoints +by adding ```@Controller``` classes that implement the interface. Eg: +```java +@Controller +public class PetController implements PetApi { +// implement all PetApi methods +} +``` + +You can also use the interface to create [Spring-Cloud Feign clients](http://projects.spring.io/spring-cloud/spring-cloud.html#spring-cloud-feign-inheritance).Eg: +```java +@FeignClient(name="pet", url="http://petstore.swagger.io/v2") +public interface PetClient extends PetApi { + +} +``` diff --git a/samples/server/petstore/springboot-generics/pom.xml b/samples/server/petstore/springboot-generics/pom.xml new file mode 100644 index 000000000000..ca3b2c7cfdd7 --- /dev/null +++ b/samples/server/petstore/springboot-generics/pom.xml @@ -0,0 +1,76 @@ + + 4.0.0 + org.openapitools + openapi-spring + jar + openapi-spring + 1.0.0 + + 17 + ${java.version} + UTF-8 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.13 + + + + + src/main/java + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar + + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.data + spring-data-commons + + + + com.google.code.findbugs + jsr305 + 3.0.2 + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + org.openapitools + jackson-databind-nullable + 0.2.10 + + + + org.springframework.boot + spring-boot-starter-validation + + + com.fasterxml.jackson.core + jackson-databind + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ApiUtil.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ApiUtil.java new file mode 100644 index 000000000000..44bf770ccc47 --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ApiUtil.java @@ -0,0 +1,21 @@ +package org.openapitools.api; + +import org.springframework.web.context.request.NativeWebRequest; + +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +public class ApiUtil { + public static void setExampleResponse(NativeWebRequest req, String contentType, String example) { + try { + HttpServletResponse res = req.getNativeResponse(HttpServletResponse.class); + if (res != null) { + res.setCharacterEncoding("UTF-8"); + res.addHeader("Content-Type", contentType); + res.getWriter().print(example); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ObservabilityApi.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ObservabilityApi.java new file mode 100644 index 000000000000..abb2d8bc12a7 --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ObservabilityApi.java @@ -0,0 +1,41 @@ +/* + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.23.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.openapitools.api; + +import org.openapitools.model.LogEntry; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import jakarta.annotation.Generated; + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +@Validated +@RequestMapping("${openapi.openAPIPetstoreGenericsTest.base-path:}") +public interface ObservabilityApi { + + String PATH_GET_LATEST_LOG_ENTRY = "/logs/latest"; + /** + * GET /logs/latest : Get latest log entry (Tier 3 — structural cluster detection, NOT substituted) + * + * @return Log entry (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = ObservabilityApi.PATH_GET_LATEST_LOG_ENTRY, + produces = { "application/json" } + ) + ResponseEntity getLatestLogEntry( + + ); + +} diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/PageApi.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/PageApi.java new file mode 100644 index 000000000000..df195d1764e6 --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/PageApi.java @@ -0,0 +1,83 @@ +/* + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.23.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.openapitools.api; + +import org.openapitools.configuration.ApiResponse; +import org.springframework.lang.Nullable; +import org.springframework.data.domain.Page; +import org.openapitools.model.Pet; +import org.openapitools.model.User; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import jakarta.annotation.Generated; + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +@Validated +@RequestMapping("${openapi.openAPIPetstoreGenericsTest.base-path:}") +public interface PageApi { + + String PATH_LIST_PETS = "/pets"; + /** + * GET /pets : List pets (Tier 2 — suffix Page, slotArray content, allOf form) + * + * @param page (optional, default to 0) + * @param size (optional, default to 20) + * @return Paged list of pets (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PageApi.PATH_LIST_PETS, + produces = { "application/json" } + ) + ResponseEntity> listPets( + @Valid @RequestParam(value = "page", required = false, defaultValue = "0") Integer page, + @Valid @RequestParam(value = "size", required = false, defaultValue = "20") Integer size + ); + + + String PATH_LIST_USER_RESPONSES = "/users/responses/page"; + /** + * GET /users/responses/page : List user responses (Tier 2 recursive - Page<UserResponse> expands to Page<ApiResponse<User>>) + * + * @return Paged list of user responses (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PageApi.PATH_LIST_USER_RESPONSES, + produces = { "application/json" } + ) + ResponseEntity>> listUserResponses( + + ); + + + String PATH_LIST_USERS = "/users"; + /** + * GET /users : List users (Tier 2 — suffix Page, slotArray content, flat-object form) + * + * @param page (optional, default to 0) + * @param size (optional, default to 20) + * @return Paged list of users (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PageApi.PATH_LIST_USERS, + produces = { "application/json" } + ) + ResponseEntity> listUsers( + @Valid @RequestParam(value = "page", required = false, defaultValue = "0") Integer page, + @Valid @RequestParam(value = "size", required = false, defaultValue = "20") Integer size + ); + +} diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ResponseApi.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ResponseApi.java new file mode 100644 index 000000000000..bc0a585166be --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ResponseApi.java @@ -0,0 +1,114 @@ +/* + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.23.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.openapitools.api; + +import org.openapitools.configuration.ApiResponse; +import org.openapitools.model.NotificationBatch; +import org.openapitools.model.Order; +import org.openapitools.model.OrderDetails; +import org.openapitools.model.Pet; +import org.openapitools.model.User; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import jakarta.annotation.Generated; + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +@Validated +@RequestMapping("${openapi.openAPIPetstoreGenericsTest.base-path:}") +public interface ResponseApi { + + String PATH_GET_NOTIFICATION_BATCH = "/notifications"; + /** + * GET /notifications : Get notification batch (non-generic model with array-of-generic-instance property) + * + * @return Batch of user response notifications (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = ResponseApi.PATH_GET_NOTIFICATION_BATCH, + produces = { "application/json" } + ) + ResponseEntity getNotificationBatch( + + ); + + + String PATH_GET_ORDER_DETAILS = "/orders/{id}/details"; + /** + * GET /orders/{id}/details : Get order details (non-generic model with generic-instance property) + * + * @param id (required) + * @return Order details (userResult property should be substituted) (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = ResponseApi.PATH_GET_ORDER_DETAILS, + produces = { "application/json" } + ) + ResponseEntity getOrderDetails( + @PathVariable("id") String id + ); + + + String PATH_GET_ORDER_RESPONSE = "/orders/{id}/response"; + /** + * GET /orders/{id}/response : Get order response (Tier 2 — suffix Response, slot data, external shared $ref) + * + * @param id (required) + * @return Order wrapped in ApiResponse (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = ResponseApi.PATH_GET_ORDER_RESPONSE, + produces = { "application/json" } + ) + ResponseEntity> getOrderResponse( + @PathVariable("id") String id + ); + + + String PATH_GET_PET_RESPONSE = "/pets/{id}/response"; + /** + * GET /pets/{id}/response : Get pet response (Tier 2 — suffix Response, slot data, external domain $ref) + * + * @param id (required) + * @return Pet wrapped in ApiResponse (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = ResponseApi.PATH_GET_PET_RESPONSE, + produces = { "application/json" } + ) + ResponseEntity> getPetResponse( + @PathVariable("id") String id + ); + + + String PATH_GET_USER_RESPONSE = "/users/{id}/response"; + /** + * GET /users/{id}/response : Get user response (Tier 2 — suffix Response, slot data) + * + * @param id (required) + * @return User wrapped in ApiResponse (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = ResponseApi.PATH_GET_USER_RESPONSE, + produces = { "application/json" } + ) + ResponseEntity> getUserResponse( + @PathVariable("id") String id + ); + +} diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ResultApi.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ResultApi.java new file mode 100644 index 000000000000..ed8638ac98c4 --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ResultApi.java @@ -0,0 +1,81 @@ +/* + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.23.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.openapitools.api; + +import org.openapitools.configuration.ApiResponse; +import org.openapitools.model.Order; +import org.openapitools.model.PaymentError; +import org.openapitools.configuration.Result; +import org.openapitools.model.User; +import org.openapitools.model.ValidationError; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import jakarta.annotation.Generated; + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +@Validated +@RequestMapping("${openapi.openAPIPetstoreGenericsTest.base-path:}") +public interface ResultApi { + + String PATH_GET_ORDER_ERROR_RESULT = "/orders/{id}/error-result"; + /** + * GET /orders/{id}/error-result : Get order error result (Tier 2 — multi-param slots, data:T + error:E) + * + * @param id (required) + * @return Order wrapped in Result<Order, PaymentError> (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = ResultApi.PATH_GET_ORDER_ERROR_RESULT, + produces = { "application/json" } + ) + ResponseEntity> getOrderErrorResult( + @PathVariable("id") String id + ); + + + String PATH_GET_USER_ERROR_RESULT = "/users/{id}/error-result"; + /** + * GET /users/{id}/error-result : Get user error result (Tier 2 — multi-param slots, data:T + error:E) + * + * @param id (required) + * @return User wrapped in Result<User, ValidationError> (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = ResultApi.PATH_GET_USER_ERROR_RESULT, + produces = { "application/json" } + ) + ResponseEntity> getUserErrorResult( + @PathVariable("id") String id + ); + + + String PATH_GET_USER_RESPONSE_ERROR_RESULT = "/users/{id}/response-error-result"; + /** + * GET /users/{id}/response-error-result : Get user response-error result (Tier 2 recursive slot - data arg is itself a generic instance) + * + * @param id (required) + * @return UserResponse (ApiResponse<User>) wrapped in Result<T,E> (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = ResultApi.PATH_GET_USER_RESPONSE_ERROR_RESULT, + produces = { "application/json" } + ) + ResponseEntity, ValidationError>> getUserResponseErrorResult( + @PathVariable("id") String id + ); + +} diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/SearchApi.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/SearchApi.java new file mode 100644 index 000000000000..23f8b2b4e2ca --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/SearchApi.java @@ -0,0 +1,43 @@ +/* + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.23.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.openapitools.api; + +import org.springframework.lang.Nullable; +import org.openapitools.model.SearchResult; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import jakarta.annotation.Generated; + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +@Validated +@RequestMapping("${openapi.openAPIPetstoreGenericsTest.base-path:}") +public interface SearchApi { + + String PATH_SEARCH = "/search"; + /** + * GET /search : Search (SearchResult NOT matched by any pattern) + * + * @param q (optional) + * @return Search result (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = SearchApi.PATH_SEARCH, + produces = { "application/json" } + ) + ResponseEntity search( + @Valid @RequestParam(value = "q", required = false) @Nullable String q + ); + +} diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/VendorApi.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/VendorApi.java new file mode 100644 index 000000000000..6190305873dd --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/VendorApi.java @@ -0,0 +1,42 @@ +/* + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.23.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.openapitools.api; + +import org.openapitools.model.User; +import com.example.generic.VendorResult; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import jakarta.annotation.Generated; + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +@Validated +@RequestMapping("${openapi.openAPIPetstoreGenericsTest.base-path:}") +public interface VendorApi { + + String PATH_GET_VENDOR_USER_RESULT = "/vendor/user-result"; + /** + * GET /vendor/user-result : Get vendor user result (Tier 1 — x-generic-class vendor extension) + * + * @return User result using vendor-extension-defined generic class (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = VendorApi.PATH_GET_VENDOR_USER_RESULT, + produces = { "application/json" } + ) + ResponseEntity> getVendorUserResult( + + ); + +} diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/configuration/ApiResponse.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/configuration/ApiResponse.java new file mode 100644 index 000000000000..2af0dac59368 --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/configuration/ApiResponse.java @@ -0,0 +1,32 @@ +package org.openapitools.configuration; +/** + * Generic class generated by openapi-generator from schema pattern 'ApiResponse'. + * Type parameters correspond to the varying domain types. + * + *

To use your own class instead, supply a fully-qualified class name via + * {@code importMappings.ApiResponse} in the generator config. + */ +public class ApiResponse { + private T data; + private String status; + private String message; + public ApiResponse() {} + public T getData() { return data; } + + public ApiResponse setData(T data) { + this.data = data; + return this; + } + public String getStatus() { return status; } + + public ApiResponse setStatus(String status) { + this.status = status; + return this; + } + public String getMessage() { return message; } + + public ApiResponse setMessage(String message) { + this.message = message; + return this; + } +} diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/configuration/Result.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/configuration/Result.java new file mode 100644 index 000000000000..7835aa1fed25 --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/configuration/Result.java @@ -0,0 +1,32 @@ +package org.openapitools.configuration; +/** + * Generic class generated by openapi-generator from schema pattern 'Result'. + * Type parameters correspond to the varying domain types. + * + *

To use your own class instead, supply a fully-qualified class name via + * {@code importMappings.Result} in the generator config. + */ +public class Result { + private T data; + private E error; + private Boolean success; + public Result() {} + public T getData() { return data; } + + public Result setData(T data) { + this.data = data; + return this; + } + public E getError() { return error; } + + public Result setError(E error) { + this.error = error; + return this; + } + public Boolean getSuccess() { return success; } + + public Result setSuccess(Boolean success) { + this.success = success; + return this; + } +} diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/LogEntry.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/LogEntry.java new file mode 100644 index 000000000000..8628adab707d --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/LogEntry.java @@ -0,0 +1,135 @@ +package org.openapitools.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.time.OffsetDateTime; +import org.openapitools.model.LogEntryData; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.lang.Nullable; +import org.openapitools.jackson.nullable.JsonNullable; +import java.io.Serializable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Log entry wrapper. Same structure as MetricsEntry except 'data' points to LogEntryData. These two form a Tier 3 cluster suggestion. + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +public class LogEntry implements Serializable { + + private static final long serialVersionUID = 1L; + + private @Nullable LogEntryData data; + + private @Nullable String severity; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private @Nullable OffsetDateTime timestamp; + + public LogEntry data(@Nullable LogEntryData data) { + this.data = data; + return this; + } + + /** + * Get data + * @return data + */ + @Valid + @JsonProperty("data") + public @Nullable LogEntryData getData() { + return data; + } + + @JsonProperty("data") + public void setData(@Nullable LogEntryData data) { + this.data = data; + } + + public LogEntry severity(@Nullable String severity) { + this.severity = severity; + return this; + } + + /** + * Get severity + * @return severity + */ + + @JsonProperty("severity") + public @Nullable String getSeverity() { + return severity; + } + + @JsonProperty("severity") + public void setSeverity(@Nullable String severity) { + this.severity = severity; + } + + public LogEntry timestamp(@Nullable OffsetDateTime timestamp) { + this.timestamp = timestamp; + return this; + } + + /** + * Get timestamp + * @return timestamp + */ + @Valid + @JsonProperty("timestamp") + public @Nullable OffsetDateTime getTimestamp() { + return timestamp; + } + + @JsonProperty("timestamp") + public void setTimestamp(@Nullable OffsetDateTime timestamp) { + this.timestamp = timestamp; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + LogEntry logEntry = (LogEntry) o; + return Objects.equals(this.data, logEntry.data) && + Objects.equals(this.severity, logEntry.severity) && + Objects.equals(this.timestamp, logEntry.timestamp); + } + + @Override + public int hashCode() { + return Objects.hash(data, severity, timestamp); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class LogEntry {\n"); + sb.append(" data: ").append(toIndentedString(data)).append("\n"); + sb.append(" severity: ").append(toIndentedString(severity)).append("\n"); + sb.append(" timestamp: ").append(toIndentedString(timestamp)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(@Nullable Object o) { + return o == null ? "null" : o.toString().replace("\n", "\n "); + } +} + diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/LogEntryData.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/LogEntryData.java new file mode 100644 index 000000000000..6eef5f913156 --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/LogEntryData.java @@ -0,0 +1,131 @@ +package org.openapitools.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import org.springframework.lang.Nullable; +import org.openapitools.jackson.nullable.JsonNullable; +import java.io.Serializable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Payload for a log entry + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +public class LogEntryData implements Serializable { + + private static final long serialVersionUID = 1L; + + private @Nullable String level; + + private @Nullable String message; + + private @Nullable String source; + + public LogEntryData level(@Nullable String level) { + this.level = level; + return this; + } + + /** + * Get level + * @return level + */ + + @JsonProperty("level") + public @Nullable String getLevel() { + return level; + } + + @JsonProperty("level") + public void setLevel(@Nullable String level) { + this.level = level; + } + + public LogEntryData message(@Nullable String message) { + this.message = message; + return this; + } + + /** + * Get message + * @return message + */ + + @JsonProperty("message") + public @Nullable String getMessage() { + return message; + } + + @JsonProperty("message") + public void setMessage(@Nullable String message) { + this.message = message; + } + + public LogEntryData source(@Nullable String source) { + this.source = source; + return this; + } + + /** + * Get source + * @return source + */ + + @JsonProperty("source") + public @Nullable String getSource() { + return source; + } + + @JsonProperty("source") + public void setSource(@Nullable String source) { + this.source = source; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + LogEntryData logEntryData = (LogEntryData) o; + return Objects.equals(this.level, logEntryData.level) && + Objects.equals(this.message, logEntryData.message) && + Objects.equals(this.source, logEntryData.source); + } + + @Override + public int hashCode() { + return Objects.hash(level, message, source); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class LogEntryData {\n"); + sb.append(" level: ").append(toIndentedString(level)).append("\n"); + sb.append(" message: ").append(toIndentedString(message)).append("\n"); + sb.append(" source: ").append(toIndentedString(source)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(@Nullable Object o) { + return o == null ? "null" : o.toString().replace("\n", "\n "); + } +} + diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/MetricsEntry.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/MetricsEntry.java new file mode 100644 index 000000000000..237ea30ed468 --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/MetricsEntry.java @@ -0,0 +1,135 @@ +package org.openapitools.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.time.OffsetDateTime; +import org.openapitools.model.MetricsEntryData; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.lang.Nullable; +import org.openapitools.jackson.nullable.JsonNullable; +import java.io.Serializable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Metrics entry wrapper. Same structure as LogEntry except 'data' points to MetricsEntryData. These two form a Tier 3 cluster suggestion. + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +public class MetricsEntry implements Serializable { + + private static final long serialVersionUID = 1L; + + private @Nullable MetricsEntryData data; + + private @Nullable String severity; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + private @Nullable OffsetDateTime timestamp; + + public MetricsEntry data(@Nullable MetricsEntryData data) { + this.data = data; + return this; + } + + /** + * Get data + * @return data + */ + @Valid + @JsonProperty("data") + public @Nullable MetricsEntryData getData() { + return data; + } + + @JsonProperty("data") + public void setData(@Nullable MetricsEntryData data) { + this.data = data; + } + + public MetricsEntry severity(@Nullable String severity) { + this.severity = severity; + return this; + } + + /** + * Get severity + * @return severity + */ + + @JsonProperty("severity") + public @Nullable String getSeverity() { + return severity; + } + + @JsonProperty("severity") + public void setSeverity(@Nullable String severity) { + this.severity = severity; + } + + public MetricsEntry timestamp(@Nullable OffsetDateTime timestamp) { + this.timestamp = timestamp; + return this; + } + + /** + * Get timestamp + * @return timestamp + */ + @Valid + @JsonProperty("timestamp") + public @Nullable OffsetDateTime getTimestamp() { + return timestamp; + } + + @JsonProperty("timestamp") + public void setTimestamp(@Nullable OffsetDateTime timestamp) { + this.timestamp = timestamp; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MetricsEntry metricsEntry = (MetricsEntry) o; + return Objects.equals(this.data, metricsEntry.data) && + Objects.equals(this.severity, metricsEntry.severity) && + Objects.equals(this.timestamp, metricsEntry.timestamp); + } + + @Override + public int hashCode() { + return Objects.hash(data, severity, timestamp); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class MetricsEntry {\n"); + sb.append(" data: ").append(toIndentedString(data)).append("\n"); + sb.append(" severity: ").append(toIndentedString(severity)).append("\n"); + sb.append(" timestamp: ").append(toIndentedString(timestamp)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(@Nullable Object o) { + return o == null ? "null" : o.toString().replace("\n", "\n "); + } +} + diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/MetricsEntryData.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/MetricsEntryData.java new file mode 100644 index 000000000000..21e00be46bb2 --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/MetricsEntryData.java @@ -0,0 +1,131 @@ +package org.openapitools.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import org.springframework.lang.Nullable; +import org.openapitools.jackson.nullable.JsonNullable; +import java.io.Serializable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Payload for a metrics entry + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +public class MetricsEntryData implements Serializable { + + private static final long serialVersionUID = 1L; + + private @Nullable String metricName; + + private @Nullable Double value; + + private @Nullable String unit; + + public MetricsEntryData metricName(@Nullable String metricName) { + this.metricName = metricName; + return this; + } + + /** + * Get metricName + * @return metricName + */ + + @JsonProperty("metricName") + public @Nullable String getMetricName() { + return metricName; + } + + @JsonProperty("metricName") + public void setMetricName(@Nullable String metricName) { + this.metricName = metricName; + } + + public MetricsEntryData value(@Nullable Double value) { + this.value = value; + return this; + } + + /** + * Get value + * @return value + */ + + @JsonProperty("value") + public @Nullable Double getValue() { + return value; + } + + @JsonProperty("value") + public void setValue(@Nullable Double value) { + this.value = value; + } + + public MetricsEntryData unit(@Nullable String unit) { + this.unit = unit; + return this; + } + + /** + * Get unit + * @return unit + */ + + @JsonProperty("unit") + public @Nullable String getUnit() { + return unit; + } + + @JsonProperty("unit") + public void setUnit(@Nullable String unit) { + this.unit = unit; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MetricsEntryData metricsEntryData = (MetricsEntryData) o; + return Objects.equals(this.metricName, metricsEntryData.metricName) && + Objects.equals(this.value, metricsEntryData.value) && + Objects.equals(this.unit, metricsEntryData.unit); + } + + @Override + public int hashCode() { + return Objects.hash(metricName, value, unit); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class MetricsEntryData {\n"); + sb.append(" metricName: ").append(toIndentedString(metricName)).append("\n"); + sb.append(" value: ").append(toIndentedString(value)).append("\n"); + sb.append(" unit: ").append(toIndentedString(unit)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(@Nullable Object o) { + return o == null ? "null" : o.toString().replace("\n", "\n "); + } +} + diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/NotificationBatch.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/NotificationBatch.java new file mode 100644 index 000000000000..c9f05f14aa17 --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/NotificationBatch.java @@ -0,0 +1,120 @@ +package org.openapitools.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.springframework.lang.Nullable; +import org.openapitools.model.User; +import org.openapitools.configuration.ApiResponse; +import org.openapitools.jackson.nullable.JsonNullable; +import java.io.Serializable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * A non-generic model with an array property of a generic-instance type. Tests array property substitution: responses type → List<ApiResponse<User>> + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +public class NotificationBatch implements Serializable { + + private static final long serialVersionUID = 1L; + + private List<@Valid ApiResponse> responses = new ArrayList<>(); + + private @Nullable String batchId; + + public NotificationBatch responses(List<@Valid ApiResponse> responses) { + this.responses = responses; + return this; + } + + public NotificationBatch addResponsesItem(ApiResponse responsesItem) { + if (this.responses == null) { + this.responses = new ArrayList<>(); + } + this.responses.add(responsesItem); + return this; + } + + /** + * Get responses + * @return responses + */ + @Valid + @JsonProperty("responses") + public List<@Valid ApiResponse> getResponses() { + return responses; + } + + @JsonProperty("responses") + public void setResponses(List<@Valid ApiResponse> responses) { + this.responses = responses; + } + + public NotificationBatch batchId(@Nullable String batchId) { + this.batchId = batchId; + return this; + } + + /** + * Get batchId + * @return batchId + */ + + @JsonProperty("batchId") + public @Nullable String getBatchId() { + return batchId; + } + + @JsonProperty("batchId") + public void setBatchId(@Nullable String batchId) { + this.batchId = batchId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + NotificationBatch notificationBatch = (NotificationBatch) o; + return Objects.equals(this.responses, notificationBatch.responses) && + Objects.equals(this.batchId, notificationBatch.batchId); + } + + @Override + public int hashCode() { + return Objects.hash(responses, batchId); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class NotificationBatch {\n"); + sb.append(" responses: ").append(toIndentedString(responses)).append("\n"); + sb.append(" batchId: ").append(toIndentedString(batchId)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(@Nullable Object o) { + return o == null ? "null" : o.toString().replace("\n", "\n "); + } +} + diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/Order.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/Order.java new file mode 100644 index 000000000000..20a018afb52d --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/Order.java @@ -0,0 +1,142 @@ +package org.openapitools.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import org.springframework.lang.Nullable; +import org.openapitools.jackson.nullable.JsonNullable; +import java.io.Serializable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Order + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +public class Order implements Serializable { + + private static final long serialVersionUID = 1L; + + private String orderId; + + private @Nullable Integer quantity; + + private @Nullable Double totalPrice; + + public Order() { + super(); + } + + /** + * Constructor with only required parameters + */ + public Order(String orderId) { + this.orderId = orderId; + } + + public Order orderId(String orderId) { + this.orderId = orderId; + return this; + } + + /** + * Get orderId + * @return orderId + */ + @NotNull + @JsonProperty("orderId") + public String getOrderId() { + return orderId; + } + + @JsonProperty("orderId") + public void setOrderId(String orderId) { + this.orderId = orderId; + } + + public Order quantity(@Nullable Integer quantity) { + this.quantity = quantity; + return this; + } + + /** + * Get quantity + * @return quantity + */ + + @JsonProperty("quantity") + public @Nullable Integer getQuantity() { + return quantity; + } + + @JsonProperty("quantity") + public void setQuantity(@Nullable Integer quantity) { + this.quantity = quantity; + } + + public Order totalPrice(@Nullable Double totalPrice) { + this.totalPrice = totalPrice; + return this; + } + + /** + * Get totalPrice + * @return totalPrice + */ + + @JsonProperty("totalPrice") + public @Nullable Double getTotalPrice() { + return totalPrice; + } + + @JsonProperty("totalPrice") + public void setTotalPrice(@Nullable Double totalPrice) { + this.totalPrice = totalPrice; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Order order = (Order) o; + return Objects.equals(this.orderId, order.orderId) && + Objects.equals(this.quantity, order.quantity) && + Objects.equals(this.totalPrice, order.totalPrice); + } + + @Override + public int hashCode() { + return Objects.hash(orderId, quantity, totalPrice); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class Order {\n"); + sb.append(" orderId: ").append(toIndentedString(orderId)).append("\n"); + sb.append(" quantity: ").append(toIndentedString(quantity)).append("\n"); + sb.append(" totalPrice: ").append(toIndentedString(totalPrice)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(@Nullable Object o) { + return o == null ? "null" : o.toString().replace("\n", "\n "); + } +} + diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/OrderDetails.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/OrderDetails.java new file mode 100644 index 000000000000..3aebd53a261c --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/OrderDetails.java @@ -0,0 +1,134 @@ +package org.openapitools.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import org.openapitools.model.Pet; +import org.springframework.lang.Nullable; +import org.openapitools.model.User; +import org.openapitools.configuration.ApiResponse; +import org.openapitools.jackson.nullable.JsonNullable; +import java.io.Serializable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * A non-generic model whose userResult property references a generic instance. Tests property-level substitution: userResult type → ApiResponse<User> while pet (a plain domain type) is left unchanged. + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +public class OrderDetails implements Serializable { + + private static final long serialVersionUID = 1L; + + private @Nullable ApiResponse userResult; + + private @Nullable Pet pet; + + private @Nullable String orderId; + + public OrderDetails userResult(@Nullable ApiResponse userResult) { + this.userResult = userResult; + return this; + } + + /** + * Get userResult + * @return userResult + */ + @Valid + @JsonProperty("userResult") + public @Nullable ApiResponse getUserResult() { + return userResult; + } + + @JsonProperty("userResult") + public void setUserResult(@Nullable ApiResponse userResult) { + this.userResult = userResult; + } + + public OrderDetails pet(@Nullable Pet pet) { + this.pet = pet; + return this; + } + + /** + * Get pet + * @return pet + */ + @Valid + @JsonProperty("pet") + public @Nullable Pet getPet() { + return pet; + } + + @JsonProperty("pet") + public void setPet(@Nullable Pet pet) { + this.pet = pet; + } + + public OrderDetails orderId(@Nullable String orderId) { + this.orderId = orderId; + return this; + } + + /** + * Get orderId + * @return orderId + */ + + @JsonProperty("orderId") + public @Nullable String getOrderId() { + return orderId; + } + + @JsonProperty("orderId") + public void setOrderId(@Nullable String orderId) { + this.orderId = orderId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + OrderDetails orderDetails = (OrderDetails) o; + return Objects.equals(this.userResult, orderDetails.userResult) && + Objects.equals(this.pet, orderDetails.pet) && + Objects.equals(this.orderId, orderDetails.orderId); + } + + @Override + public int hashCode() { + return Objects.hash(userResult, pet, orderId); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class OrderDetails {\n"); + sb.append(" userResult: ").append(toIndentedString(userResult)).append("\n"); + sb.append(" pet: ").append(toIndentedString(pet)).append("\n"); + sb.append(" orderId: ").append(toIndentedString(orderId)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(@Nullable Object o) { + return o == null ? "null" : o.toString().replace("\n", "\n "); + } +} + diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/PageMeta.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/PageMeta.java new file mode 100644 index 000000000000..36ca6c12591f --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/PageMeta.java @@ -0,0 +1,169 @@ +package org.openapitools.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import org.springframework.lang.Nullable; +import org.openapitools.jackson.nullable.JsonNullable; +import java.io.Serializable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Pagination metadata (inline) + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +public class PageMeta implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long size; + + private Long number; + + private Long totalElements; + + private Long totalPages; + + public PageMeta() { + super(); + } + + /** + * Constructor with only required parameters + */ + public PageMeta(Long size, Long number, Long totalElements, Long totalPages) { + this.size = size; + this.number = number; + this.totalElements = totalElements; + this.totalPages = totalPages; + } + + public PageMeta size(Long size) { + this.size = size; + return this; + } + + /** + * Get size + * @return size + */ + @NotNull + @JsonProperty("size") + public Long getSize() { + return size; + } + + @JsonProperty("size") + public void setSize(Long size) { + this.size = size; + } + + public PageMeta number(Long number) { + this.number = number; + return this; + } + + /** + * Get number + * @return number + */ + @NotNull + @JsonProperty("number") + public Long getNumber() { + return number; + } + + @JsonProperty("number") + public void setNumber(Long number) { + this.number = number; + } + + public PageMeta totalElements(Long totalElements) { + this.totalElements = totalElements; + return this; + } + + /** + * Get totalElements + * @return totalElements + */ + @NotNull + @JsonProperty("totalElements") + public Long getTotalElements() { + return totalElements; + } + + @JsonProperty("totalElements") + public void setTotalElements(Long totalElements) { + this.totalElements = totalElements; + } + + public PageMeta totalPages(Long totalPages) { + this.totalPages = totalPages; + return this; + } + + /** + * Get totalPages + * @return totalPages + */ + @NotNull + @JsonProperty("totalPages") + public Long getTotalPages() { + return totalPages; + } + + @JsonProperty("totalPages") + public void setTotalPages(Long totalPages) { + this.totalPages = totalPages; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PageMeta pageMeta = (PageMeta) o; + return Objects.equals(this.size, pageMeta.size) && + Objects.equals(this.number, pageMeta.number) && + Objects.equals(this.totalElements, pageMeta.totalElements) && + Objects.equals(this.totalPages, pageMeta.totalPages); + } + + @Override + public int hashCode() { + return Objects.hash(size, number, totalElements, totalPages); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class PageMeta {\n"); + sb.append(" size: ").append(toIndentedString(size)).append("\n"); + sb.append(" number: ").append(toIndentedString(number)).append("\n"); + sb.append(" totalElements: ").append(toIndentedString(totalElements)).append("\n"); + sb.append(" totalPages: ").append(toIndentedString(totalPages)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(@Nullable Object o) { + return o == null ? "null" : o.toString().replace("\n", "\n "); + } +} + diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/PaymentError.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/PaymentError.java new file mode 100644 index 000000000000..956dafe2503a --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/PaymentError.java @@ -0,0 +1,143 @@ +package org.openapitools.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import org.springframework.lang.Nullable; +import org.openapitools.jackson.nullable.JsonNullable; +import java.io.Serializable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Payment processing error details + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +public class PaymentError implements Serializable { + + private static final long serialVersionUID = 1L; + + private String reason; + + private Double amount; + + private @Nullable Boolean retryable; + + public PaymentError() { + super(); + } + + /** + * Constructor with only required parameters + */ + public PaymentError(String reason, Double amount) { + this.reason = reason; + this.amount = amount; + } + + public PaymentError reason(String reason) { + this.reason = reason; + return this; + } + + /** + * Reason for payment failure + * @return reason + */ + @NotNull + @JsonProperty("reason") + public String getReason() { + return reason; + } + + @JsonProperty("reason") + public void setReason(String reason) { + this.reason = reason; + } + + public PaymentError amount(Double amount) { + this.amount = amount; + return this; + } + + /** + * Amount that failed to process + * @return amount + */ + @NotNull + @JsonProperty("amount") + public Double getAmount() { + return amount; + } + + @JsonProperty("amount") + public void setAmount(Double amount) { + this.amount = amount; + } + + public PaymentError retryable(@Nullable Boolean retryable) { + this.retryable = retryable; + return this; + } + + /** + * Whether the payment can be retried + * @return retryable + */ + + @JsonProperty("retryable") + public @Nullable Boolean getRetryable() { + return retryable; + } + + @JsonProperty("retryable") + public void setRetryable(@Nullable Boolean retryable) { + this.retryable = retryable; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + PaymentError paymentError = (PaymentError) o; + return Objects.equals(this.reason, paymentError.reason) && + Objects.equals(this.amount, paymentError.amount) && + Objects.equals(this.retryable, paymentError.retryable); + } + + @Override + public int hashCode() { + return Objects.hash(reason, amount, retryable); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class PaymentError {\n"); + sb.append(" reason: ").append(toIndentedString(reason)).append("\n"); + sb.append(" amount: ").append(toIndentedString(amount)).append("\n"); + sb.append(" retryable: ").append(toIndentedString(retryable)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(@Nullable Object o) { + return o == null ? "null" : o.toString().replace("\n", "\n "); + } +} + diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/Pet.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/Pet.java new file mode 100644 index 000000000000..4064d1b081ad --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/Pet.java @@ -0,0 +1,142 @@ +package org.openapitools.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import org.springframework.lang.Nullable; +import org.openapitools.jackson.nullable.JsonNullable; +import java.io.Serializable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Pet + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +public class Pet implements Serializable { + + private static final long serialVersionUID = 1L; + + private @Nullable Long id; + + private String name; + + private @Nullable String species; + + public Pet() { + super(); + } + + /** + * Constructor with only required parameters + */ + public Pet(String name) { + this.name = name; + } + + public Pet id(@Nullable Long id) { + this.id = id; + return this; + } + + /** + * Get id + * @return id + */ + + @JsonProperty("id") + public @Nullable Long getId() { + return id; + } + + @JsonProperty("id") + public void setId(@Nullable Long id) { + this.id = id; + } + + public Pet name(String name) { + this.name = name; + return this; + } + + /** + * Get name + * @return name + */ + @NotNull + @JsonProperty("name") + public String getName() { + return name; + } + + @JsonProperty("name") + public void setName(String name) { + this.name = name; + } + + public Pet species(@Nullable String species) { + this.species = species; + return this; + } + + /** + * Get species + * @return species + */ + + @JsonProperty("species") + public @Nullable String getSpecies() { + return species; + } + + @JsonProperty("species") + public void setSpecies(@Nullable String species) { + this.species = species; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Pet pet = (Pet) o; + return Objects.equals(this.id, pet.id) && + Objects.equals(this.name, pet.name) && + Objects.equals(this.species, pet.species); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, species); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class Pet {\n"); + sb.append(" id: ").append(toIndentedString(id)).append("\n"); + sb.append(" name: ").append(toIndentedString(name)).append("\n"); + sb.append(" species: ").append(toIndentedString(species)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(@Nullable Object o) { + return o == null ? "null" : o.toString().replace("\n", "\n "); + } +} + diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/SearchResult.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/SearchResult.java new file mode 100644 index 000000000000..b6c41a5a3f7e --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/SearchResult.java @@ -0,0 +1,176 @@ +package org.openapitools.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.springframework.lang.Nullable; +import org.openapitools.jackson.nullable.JsonNullable; +import java.io.Serializable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Search result — unique structure, not matched by any pattern + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +public class SearchResult implements Serializable { + + private static final long serialVersionUID = 1L; + + private @Nullable String query; + + private @Nullable Long totalHits; + + private List results = new ArrayList<>(); + + private Map facets = new HashMap<>(); + + public SearchResult query(@Nullable String query) { + this.query = query; + return this; + } + + /** + * Get query + * @return query + */ + + @JsonProperty("query") + public @Nullable String getQuery() { + return query; + } + + @JsonProperty("query") + public void setQuery(@Nullable String query) { + this.query = query; + } + + public SearchResult totalHits(@Nullable Long totalHits) { + this.totalHits = totalHits; + return this; + } + + /** + * Get totalHits + * @return totalHits + */ + + @JsonProperty("totalHits") + public @Nullable Long getTotalHits() { + return totalHits; + } + + @JsonProperty("totalHits") + public void setTotalHits(@Nullable Long totalHits) { + this.totalHits = totalHits; + } + + public SearchResult results(List results) { + this.results = results; + return this; + } + + public SearchResult addResultsItem(String resultsItem) { + if (this.results == null) { + this.results = new ArrayList<>(); + } + this.results.add(resultsItem); + return this; + } + + /** + * Get results + * @return results + */ + + @JsonProperty("results") + public List getResults() { + return results; + } + + @JsonProperty("results") + public void setResults(List results) { + this.results = results; + } + + public SearchResult facets(Map facets) { + this.facets = facets; + return this; + } + + public SearchResult putFacetsItem(String key, Integer facetsItem) { + if (this.facets == null) { + this.facets = new HashMap<>(); + } + this.facets.put(key, facetsItem); + return this; + } + + /** + * Get facets + * @return facets + */ + + @JsonProperty("facets") + public Map getFacets() { + return facets; + } + + @JsonProperty("facets") + public void setFacets(Map facets) { + this.facets = facets; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SearchResult searchResult = (SearchResult) o; + return Objects.equals(this.query, searchResult.query) && + Objects.equals(this.totalHits, searchResult.totalHits) && + Objects.equals(this.results, searchResult.results) && + Objects.equals(this.facets, searchResult.facets); + } + + @Override + public int hashCode() { + return Objects.hash(query, totalHits, results, facets); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class SearchResult {\n"); + sb.append(" query: ").append(toIndentedString(query)).append("\n"); + sb.append(" totalHits: ").append(toIndentedString(totalHits)).append("\n"); + sb.append(" results: ").append(toIndentedString(results)).append("\n"); + sb.append(" facets: ").append(toIndentedString(facets)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(@Nullable Object o) { + return o == null ? "null" : o.toString().replace("\n", "\n "); + } +} + diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/User.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/User.java new file mode 100644 index 000000000000..5f736fdfa455 --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/User.java @@ -0,0 +1,131 @@ +package org.openapitools.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import org.springframework.lang.Nullable; +import org.openapitools.jackson.nullable.JsonNullable; +import java.io.Serializable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * User + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +public class User implements Serializable { + + private static final long serialVersionUID = 1L; + + private @Nullable String id; + + private @Nullable String name; + + private @Nullable String email; + + public User id(@Nullable String id) { + this.id = id; + return this; + } + + /** + * Get id + * @return id + */ + + @JsonProperty("id") + public @Nullable String getId() { + return id; + } + + @JsonProperty("id") + public void setId(@Nullable String id) { + this.id = id; + } + + public User name(@Nullable String name) { + this.name = name; + return this; + } + + /** + * Get name + * @return name + */ + + @JsonProperty("name") + public @Nullable String getName() { + return name; + } + + @JsonProperty("name") + public void setName(@Nullable String name) { + this.name = name; + } + + public User email(@Nullable String email) { + this.email = email; + return this; + } + + /** + * Get email + * @return email + */ + + @JsonProperty("email") + public @Nullable String getEmail() { + return email; + } + + @JsonProperty("email") + public void setEmail(@Nullable String email) { + this.email = email; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + User user = (User) o; + return Objects.equals(this.id, user.id) && + Objects.equals(this.name, user.name) && + Objects.equals(this.email, user.email); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, email); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class User {\n"); + sb.append(" id: ").append(toIndentedString(id)).append("\n"); + sb.append(" name: ").append(toIndentedString(name)).append("\n"); + sb.append(" email: ").append(toIndentedString(email)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(@Nullable Object o) { + return o == null ? "null" : o.toString().replace("\n", "\n "); + } +} + diff --git a/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/ValidationError.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/ValidationError.java new file mode 100644 index 000000000000..be159cb34e69 --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/ValidationError.java @@ -0,0 +1,143 @@ +package org.openapitools.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import org.springframework.lang.Nullable; +import org.openapitools.jackson.nullable.JsonNullable; +import java.io.Serializable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Validation error details + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +public class ValidationError implements Serializable { + + private static final long serialVersionUID = 1L; + + private String field; + + private String message; + + private @Nullable String code; + + public ValidationError() { + super(); + } + + /** + * Constructor with only required parameters + */ + public ValidationError(String field, String message) { + this.field = field; + this.message = message; + } + + public ValidationError field(String field) { + this.field = field; + return this; + } + + /** + * Name of the field that failed validation + * @return field + */ + @NotNull + @JsonProperty("field") + public String getField() { + return field; + } + + @JsonProperty("field") + public void setField(String field) { + this.field = field; + } + + public ValidationError message(String message) { + this.message = message; + return this; + } + + /** + * Human-readable validation error message + * @return message + */ + @NotNull + @JsonProperty("message") + public String getMessage() { + return message; + } + + @JsonProperty("message") + public void setMessage(String message) { + this.message = message; + } + + public ValidationError code(@Nullable String code) { + this.code = code; + return this; + } + + /** + * Machine-readable error code + * @return code + */ + + @JsonProperty("code") + public @Nullable String getCode() { + return code; + } + + @JsonProperty("code") + public void setCode(@Nullable String code) { + this.code = code; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ValidationError validationError = (ValidationError) o; + return Objects.equals(this.field, validationError.field) && + Objects.equals(this.message, validationError.message) && + Objects.equals(this.code, validationError.code); + } + + @Override + public int hashCode() { + return Objects.hash(field, message, code); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class ValidationError {\n"); + sb.append(" field: ").append(toIndentedString(field)).append("\n"); + sb.append(" message: ").append(toIndentedString(message)).append("\n"); + sb.append(" code: ").append(toIndentedString(code)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(@Nullable Object o) { + return o == null ? "null" : o.toString().replace("\n", "\n "); + } +} + diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApi.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApi.java index df6af79e77d0..6de78b9bbd70 100644 --- a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApi.java +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApi.java @@ -120,6 +120,22 @@ ResponseEntity> findPetsWithArraySortRefEnum( ); + String PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF = "/pet/findWithDefaultFromAllOfRef"; + /** + * GET /pet/findWithDefaultFromAllOfRef : Find pets — size default resolved from allOf $ref (no inline default) + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF, + produces = { "application/json" } + ) + ResponseEntity> findPetsWithDefaultFromAllOfRef( + final Pageable pageable + ); + + String PATH_FIND_PETS_WITH_EXTERNAL_PARAM_REF_ARRAY_SORT = "/pet/findWithExternalParamRefArraySort"; /** * GET /pet/findWithExternalParamRefArraySort : Find pets with x-spring-paginated and sort param referenced from an external components file @@ -136,6 +152,22 @@ ResponseEntity> findPetsWithExternalParamRefArraySort( ); + String PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF = "/pet/findWithMinSizeConstraintFromAllOfRef"; + /** + * GET /pet/findWithMinSizeConstraintFromAllOfRef : Find pets — size minimum resolved from allOf $ref (no inline minimum) + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF, + produces = { "application/json" } + ) + ResponseEntity> findPetsWithMinSizeConstraintFromAllOfRef( + @ValidPageable(minSize = 5) final Pageable pageable + ); + + String PATH_FIND_PETS_WITH_MIXED_SORT_DEFAULTS = "/pet/findWithMixedSortDefaults"; /** * GET /pet/findWithMixedSortDefaults : Find pets — multiple sort defaults with mixed directions (array sort param) @@ -232,6 +264,22 @@ ResponseEntity> findPetsWithSizeConstraint( ); + String PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF = "/pet/findWithSizeConstraintFromAllOfRef"; + /** + * GET /pet/findWithSizeConstraintFromAllOfRef : Find pets — size maximum resolved from allOf $ref (no inline maximum) + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF, + produces = { "application/json" } + ) + ResponseEntity> findPetsWithSizeConstraintFromAllOfRef( + @ValidPageable(maxSize = 75) final Pageable pageable + ); + + String PATH_FIND_PETS_WITH_SORT_DEFAULT_ASC = "/pet/findWithSortDefaultAsc"; /** * GET /pet/findWithSortDefaultAsc : Find pets — sort default only (single field, no explicit direction defaults to ASC) diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApiController.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApiController.java index bef1f3a47ab0..711bfb273e32 100644 --- a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApiController.java +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApiController.java @@ -80,6 +80,21 @@ public ResponseEntity> findPetsWithoutSortEnum(Pageable pageable) { // ── @ValidPageable only ─────────────────────────────────────────────────── + @Override + public ResponseEntity> findPetsWithSizeConstraintFromAllOfRef(Pageable pageable) { + return ResponseEntity.ok(Collections.emptyList()); + } + + @Override + public ResponseEntity> findPetsWithMinSizeConstraintFromAllOfRef(Pageable pageable) { + return ResponseEntity.ok(Collections.emptyList()); + } + + @Override + public ResponseEntity> findPetsWithDefaultFromAllOfRef(Pageable pageable) { + return ResponseEntity.ok(Collections.emptyList()); + } + @Override public ResponseEntity> findPetsWithSizeConstraint(Pageable pageable) { return ResponseEntity.ok(Collections.emptyList()); diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidPageable.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidPageable.java index 04b2ce26a5fc..42995b27d115 100644 --- a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidPageable.java +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidPageable.java @@ -13,13 +13,15 @@ import java.lang.annotation.Target; /** - * Validates that the page number and page size in the annotated {@link Pageable} parameter do not - * exceed their configured maximums. + * Validates that the page number and page size in the annotated {@link Pageable} parameter are + * within their configured bounds. * *

Apply directly on a {@code Pageable} parameter. Each attribute is independently optional: *

    *
  • {@link #maxSize()} — when set (>= 0), validates {@code pageable.getPageSize() <= maxSize} *
  • {@link #maxPage()} — when set (>= 0), validates {@code pageable.getPageNumber() <= maxPage} + *
  • {@link #minSize()} — when set (>= 0), validates {@code pageable.getPageSize() >= minSize} + *
  • {@link #minPage()} — when set (>= 0), validates {@code pageable.getPageNumber() >= minPage} *
* *

Use {@link #NO_LIMIT} (= {@code -1}, the default) to leave an attribute unconstrained. @@ -43,6 +45,12 @@ /** Maximum allowed page number (0-based), or {@link #NO_LIMIT} if unconstrained. */ int maxPage() default NO_LIMIT; + /** Minimum allowed page size, or {@link #NO_LIMIT} if unconstrained. */ + int minSize() default NO_LIMIT; + + /** Minimum allowed page number (0-based), or {@link #NO_LIMIT} if unconstrained. */ + int minPage() default NO_LIMIT; + Class[] groups() default {}; Class[] payload() default {}; @@ -53,11 +61,15 @@ class PageableConstraintValidator implements ConstraintValidator= 0 && pageable.getPageSize() < minSize) { + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate() + + ": page size " + pageable.getPageSize() + + " is below minimum " + minSize) + .addPropertyNode("size") + .addConstraintViolation(); + valid = false; + } + + if (minPage >= 0 && pageable.getPageNumber() < minPage) { + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate() + + ": page number " + pageable.getPageNumber() + + " is below minimum " + minPage) + .addPropertyNode("page") + .addConstraintViolation(); + valid = false; + } + return valid; } } diff --git a/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml b/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml index 5763ce44186b..7d01a54c0532 100644 --- a/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml +++ b/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml @@ -757,6 +757,135 @@ paths: - application/json x-tags: - tag: pet + /pet/findWithSizeConstraintFromAllOfRef: + get: + operationId: findPetsWithSizeConstraintFromAllOfRef + parameters: + - explode: true + in: query + name: page + required: false + schema: + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + allOf: + - $ref: "#/components/schemas/PageSizeWithMax" + style: form + - explode: true + in: query + name: sort + required: false + schema: + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets — size maximum resolved from allOf $ref (no inline maximum) + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findWithDefaultFromAllOfRef: + get: + operationId: findPetsWithDefaultFromAllOfRef + parameters: + - explode: true + in: query + name: page + required: false + schema: + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + allOf: + - $ref: "#/components/schemas/PageSizeWithDefault" + style: form + - explode: true + in: query + name: sort + required: false + schema: + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets — size default resolved from allOf $ref (no inline default) + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findWithMinSizeConstraintFromAllOfRef: + get: + operationId: findPetsWithMinSizeConstraintFromAllOfRef + parameters: + - explode: true + in: query + name: page + required: false + schema: + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + allOf: + - $ref: "#/components/schemas/PageSizeWithMin" + style: form + - explode: true + in: query + name: sort + required: false + schema: + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets — size minimum resolved from allOf $ref (no inline minimum) + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet /nullable/check-required-only: post: operationId: checkRequiredOnly @@ -881,6 +1010,18 @@ components: - "createdAt,asc" - "createdAt,desc" type: string + PageSizeWithMax: + format: int32 + maximum: 75 + type: integer + PageSizeWithDefault: + default: 7 + format: int32 + type: integer + PageSizeWithMin: + format: int32 + minimum: 5 + type: integer Pet: example: id: 0 diff --git a/samples/server/petstore/springboot-sort-validation/src/test/java/org/openapitools/api/PetApiValidationTest.java b/samples/server/petstore/springboot-sort-validation/src/test/java/org/openapitools/api/PetApiValidationTest.java index 7e3bceea5072..3357efd832c5 100644 --- a/samples/server/petstore/springboot-sort-validation/src/test/java/org/openapitools/api/PetApiValidationTest.java +++ b/samples/server/petstore/springboot-sort-validation/src/test/java/org/openapitools/api/PetApiValidationTest.java @@ -104,6 +104,54 @@ void validPageable_unpagedPageableIsAllowed() throws Exception { .andExpect(status().isOk()); } + // ── @ValidPageable — maxSize resolved from allOf $ref ──────────────────── + // Endpoint: GET /pet/findWithSizeConstraintFromAllOfRef maxSize = 75 (resolved from allOf) + + @Test + void validPageable_allOfMaxSize_sizeBelowMaximumReturns200() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "50")) + .andExpect(status().isOk()); + } + + @Test + void validPageable_allOfMaxSize_sizeAtMaximumReturns200() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "75")) + .andExpect(status().isOk()); + } + + @Test + void validPageable_allOfMaxSize_sizeExceedsMaximumReturns400() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "76")) + .andExpect(status().isBadRequest()); + } + + // ── @ValidPageable — minSize resolved from allOf $ref ──────────────────── + // Endpoint: GET /pet/findWithMinSizeConstraintFromAllOfRef minSize = 5 (resolved from allOf) + + @Test + void validPageable_allOfMinSize_sizeAboveMinimumReturns200() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "10")) + .andExpect(status().isOk()); + } + + @Test + void validPageable_allOfMinSize_sizeAtMinimumReturns200() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "5")) + .andExpect(status().isOk()); + } + + @Test + void validPageable_allOfMinSize_sizeBelowMinimumReturns400() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "4")) + .andExpect(status().isBadRequest()); + } + // ── @ValidPageable — size and page constraints combined ─────────────────── // Endpoint: GET /pet/findWithPageAndSizeConstraint maxSize = 50, maxPage = 999