From b2cdfbac2ab1ff11ea5f1af0d5119e2b180b3bd5 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 24 Apr 2026 00:15:00 +0200 Subject: [PATCH 01/43] detect generics --- bin/configs/spring-boot-generics.yaml | 25 + .../languages/GenericPatternConfig.java | 106 +++ .../languages/GenericSchemaScanUtils.java | 720 ++++++++++++++++ .../languages/GenericSubstitutionSupport.java | 487 +++++++++++ .../languages/KotlinSpringServerCodegen.java | 334 ++----- .../codegen/languages/SpringCodegen.java | 288 ++----- .../languages/SpringPageableSupport.java | 416 +++++++++ .../java/spring/SpringCodegenTest.java | 174 ++++ .../languages/GenericSchemaScanUtilsTest.java | 814 ++++++++++++++++++ .../3_0/spring/petstore-generics-domain.yaml | 41 + .../3_0/spring/petstore-generics-shared.yaml | 46 + .../3_0/spring/petstore-generics.yaml | 400 +++++++++ .../.openapi-generator-ignore | 23 + .../.openapi-generator/FILES | 18 + .../.openapi-generator/VERSION | 1 + .../petstore/springboot-generics/README.md | 27 + .../petstore/springboot-generics/pom.xml | 76 ++ .../java/org/openapitools/api/ApiUtil.java | 21 + .../openapitools/api/ObservabilityApi.java | 41 + .../java/org/openapitools/api/PageApi.java | 66 ++ .../org/openapitools/api/ResponseApi.java | 79 ++ .../java/org/openapitools/api/SearchApi.java | 43 + .../java/org/openapitools/api/VendorApi.java | 42 + .../configuration/ApiResponse.java | 27 + .../java/org/openapitools/model/LogEntry.java | 135 +++ .../org/openapitools/model/LogEntryData.java | 131 +++ .../org/openapitools/model/MetricsEntry.java | 135 +++ .../openapitools/model/MetricsEntryData.java | 131 +++ .../java/org/openapitools/model/Order.java | 142 +++ .../java/org/openapitools/model/PageMeta.java | 169 ++++ .../main/java/org/openapitools/model/Pet.java | 142 +++ .../org/openapitools/model/SearchResult.java | 178 ++++ .../java/org/openapitools/model/User.java | 131 +++ 33 files changed, 5151 insertions(+), 458 deletions(-) create mode 100644 bin/configs/spring-boot-generics.yaml create mode 100644 modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericPatternConfig.java create mode 100644 modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericSchemaScanUtils.java create mode 100644 modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericSubstitutionSupport.java create mode 100644 modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableSupport.java create mode 100644 modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/GenericSchemaScanUtilsTest.java create mode 100644 modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics-domain.yaml create mode 100644 modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics-shared.yaml create mode 100644 modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics.yaml create mode 100644 samples/server/petstore/springboot-generics/.openapi-generator-ignore create mode 100644 samples/server/petstore/springboot-generics/.openapi-generator/FILES create mode 100644 samples/server/petstore/springboot-generics/.openapi-generator/VERSION create mode 100644 samples/server/petstore/springboot-generics/README.md create mode 100644 samples/server/petstore/springboot-generics/pom.xml create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ApiUtil.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ObservabilityApi.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/PageApi.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ResponseApi.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/SearchApi.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/VendorApi.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/configuration/ApiResponse.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/LogEntry.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/LogEntryData.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/MetricsEntry.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/MetricsEntryData.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/Order.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/PageMeta.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/Pet.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/SearchResult.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/User.java diff --git a/bin/configs/spring-boot-generics.yaml b/bin/configs/spring-boot-generics.yaml new file mode 100644 index 000000000000..aad9e38a9d70 --- /dev/null +++ b/bin/configs/spring-boot-generics.yaml @@ -0,0 +1,25 @@ +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 + discoverGenericPatterns: "true" 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..2487a20f0b63 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericPatternConfig.java @@ -0,0 +1,106 @@ +/* + * 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; + +/** + * 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.

+ * + *

The {@link #slot} / {@link #slotArray} field names identify which property of the matched + * schema holds the type parameter {@code T}.

+ * + *

Example YAML config (in a generator config file)

+ *
{@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
+ * }
+ * + *

Mode A vs Mode B

+ * + */ +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. + * + * + * + * 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}. + */ + public String slot; + + /** + * Name of the array property whose items serve as type argument {@code T}. + * Mutually exclusive with {@link #slot}. + */ + public String slotArray; + + 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; } + + @Override + public String toString() { + return "GenericPatternConfig{suffix=" + suffix + ", prefix=" + prefix + + ", genericClass=" + genericClass + ", slot=" + slot + + ", slotArray=" + slotArray + "}"; + } +} 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..5604e5191c0b --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericSchemaScanUtils.java @@ -0,0 +1,720 @@ +/* + * 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.openapitools.codegen.utils.ModelUtils; +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 { + + private static final Logger LOGGER = LoggerFactory.getLogger(GenericSchemaScanUtils.class); + + private GenericSchemaScanUtils() {} + + // ========================================================================= + // 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"}} or {@code {"content" -> "Pet"}}. + * For the current single-type-parameter implementation this map has exactly one entry. + */ + public final Map typeArgs; + /** + * The name of the slot property (the key in {@link #typeArgs}). + */ + public final String slotProperty; + /** + * Whether the slot property is an array property ({@code slotArray}), meaning the + * generated type will be {@code List} rather than {@code T}. + */ + public final boolean slotIsArray; + /** + * All properties of the matched schema, with the slot property having + * {@code typeParam="T"}. Used for Mode B class generation. + */ + public final List properties; + + public GenericInstance(String schemaName, String genericClassName, String genericClassFqn, + boolean generateClass, Map typeArgs, + String slotProperty, boolean slotIsArray, + List properties) { + this.schemaName = schemaName; + this.genericClassName = genericClassName; + this.genericClassFqn = genericClassFqn; + this.generateClass = generateClass; + this.typeArgs = Collections.unmodifiableMap(typeArgs); + 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"}. + */ + 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; + /** $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 = Collections.unmodifiableList(schemaNames); + this.varyingSlotProperty = varyingSlotProperty; + 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 property name and whether it's an array slot + String slotProperty = typeArgs.keySet().iterator().next(); + boolean slotIsArray = false; + Map props = resolveProperties(schema, openAPI); + 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, slotProperty, slotIsArray); + + result.add(new GenericInstance( + schemaName, genericClassName, + isFqn ? genericClassValue : null, + !isFqn, + typeArgs, 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 slot and whether it's an array slot + String slotName = null; + boolean slotIsArray = false; + String typeArgSchemaName = null; + + Map props = resolveProperties(schema, openAPI); + + if (pattern.slot != null && !pattern.slot.isEmpty()) { + // Expect a $ref property + String ref = findRefInProperties(props, pattern.slot, openAPI); + if (ref != null) { + slotName = pattern.slot; + slotIsArray = false; + typeArgSchemaName = extractSchemaNameFromRef(ref); + } + } else if (pattern.slotArray != null && !pattern.slotArray.isEmpty()) { + // Expect an array with items.$ref, handling allOf form too + String ref = findArrayItemRefInProperties(props, schema, pattern.slotArray, openAPI); + if (ref != null) { + slotName = pattern.slotArray; + slotIsArray = true; + typeArgSchemaName = extractSchemaNameFromRef(ref); + } + } + + if (slotName == null || typeArgSchemaName == null) { + LOGGER.debug("GenericSchemaScanUtils Tier2: schema '{}' matched pattern '{}' by name " + + "but slot '{}' / slotArray '{}' not found or not a $ref — skipping", + schemaName, pattern, pattern.slot, pattern.slotArray); + continue; + } + + boolean isFqn = pattern.genericClass.contains("."); + String genericClassName = isFqn + ? pattern.genericClass.substring(pattern.genericClass.lastIndexOf('.') + 1) + : pattern.genericClass; + + Map typeArgs = new LinkedHashMap<>(); + typeArgs.put(slotName, typeArgSchemaName); + + List properties = buildProperties(schema, openAPI, slotName, slotIsArray); + + result.add(new GenericInstance( + schemaName, genericClassName, + isFqn ? pattern.genericClass : null, + !isFqn, + typeArgs, slotName, slotIsArray, properties)); + + LOGGER.debug("GenericSchemaScanUtils Tier2: schema '{}' matched pattern '{}' → {}<{}>", + schemaName, pattern.suffix != null ? ("suffix=" + pattern.suffix) : ("prefix=" + pattern.prefix), + genericClassName, typeArgSchemaName); + 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 {@code $ref} property + * which varies 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(); + + // Build fingerprint → list of schema names + 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; // allOf or no properties + } + byFingerprint.computeIfAbsent(fp, k -> new ArrayList<>()).add(name); + } + + // For each group of 2+, look for the varying $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 + String varyingProp = findVaryingRefProperty(names, allSchemas); + if (varyingProp == null) { + continue; + } + + // Collect all varying types + List varyingTypes = new ArrayList<>(); + for (String name : names) { + Schema schema = allSchemas.get(name); + if (schema.getProperties() == null) continue; + Schema prop = (Schema) schema.getProperties().get(varyingProp); + if (prop != null && prop.get$ref() != null) { + varyingTypes.add(extractSchemaNameFromRef(prop.get$ref())); + } + } + + // Determine the most likely common suffix for the suggestion + String suggestedSuffix = commonSuffix(names); + String suggestedConfig = buildSuggestedConfig(suggestedSuffix, varyingProp, names); + + result.add(new ClusterSuggestion(new ArrayList<>(names), varyingProp, + 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; + } + + /** + * Finds the {@code $ref} string of a property in the resolved properties. + * Returns {@code null} if the property is absent or is not a {@code $ref}. + */ + @SuppressWarnings("rawtypes") + private static String findRefInProperties(Map props, String propName, + OpenAPI openAPI) { + if (props == null) return null; + Schema prop = (Schema) props.get(propName); + if (prop == null) return null; + if (prop.get$ref() != null) return prop.get$ref(); + // Might be inlined — check via ModelUtils to handle $ref resolution + Schema resolved = ModelUtils.getReferencedSchema(openAPI, prop); + return resolved != null && resolved != prop ? prop.get$ref() : null; + } + + /** + * 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 the slot property with + * {@code typeParam="T"}. + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private static List buildProperties(Schema schema, OpenAPI openAPI, + String slotName, boolean slotIsArray) { + 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); + + if (name.equals(slotName)) { + // Slot property + String format = slotIsArray && propSchema.getItems() != null + ? propSchema.getItems().getFormat() : propSchema.getFormat(); + result.add(new GenericProperty(name, slotIsArray ? "array" : "$ref", + null, "T", format, slotIsArray, 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) + // ========================================================================= + + /** + * Builds a canonical structural fingerprint for a flat-object schema. + * Returns {@code null} if the schema is an allOf or has no properties. + * + *

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.

+ */ + @SuppressWarnings("rawtypes") + static String buildStructuralFingerprint(Schema schema) { + if (schema.getAllOf() != null || schema.getProperties() == null + || schema.getProperties().isEmpty()) { + return null; + } + return schema.getProperties().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(e -> e.getKey() + ":" + propertyTypeDescriptor((Schema) e.getValue())) + .collect(Collectors.joining("|")); + } + + @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"; + } + + /** + * Finds the name of the property whose {@code $ref} target varies across all schemas + * in the group (all other {@code $ref} properties must be identical). + * Returns {@code null} if no such unique varying property exists. + */ + @SuppressWarnings("rawtypes") + private static String findVaryingRefProperty(List schemaNames, + Map allSchemas) { + if (schemaNames.isEmpty()) return null; + Schema first = allSchemas.get(schemaNames.get(0)); + if (first.getProperties() == null) return null; + + List candidates = new ArrayList<>(); + for (Map.Entry entry : first.getProperties().entrySet()) { + Schema prop = (Schema) entry.getValue(); + if (prop.get$ref() == null) continue; + candidates.add((String) entry.getKey()); + } + + String varyingProp = null; + for (String candidate : candidates) { + Set refs = new HashSet<>(); + for (String name : schemaNames) { + Schema schema = allSchemas.get(name); + if (schema.getProperties() == null) { refs.add(null); break; } + Schema prop = (Schema) schema.getProperties().get(candidate); + refs.add(prop != null ? prop.get$ref() : null); + } + if (refs.size() == schemaNames.size()) { + // All members have this property, all different + if (varyingProp != null) { + return null; // more than one varying property — not a simple generic + } + varyingProp = candidate; + } + } + return varyingProp; + } + + 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); + } + } + return first; + } + + private static String buildSuggestedConfig(String suggestedSuffix, String slotProperty, + List schemaNames) { + boolean isArray = false; // Tier 3 only handles $ref properties + String slotKey = isArray ? "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..f434961c6c27 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericSubstitutionSupport.java @@ -0,0 +1,487 @@ +/* + * 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.CodegenOperation; +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.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 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 .java} or + * {@code .kt} source file is written directly to the output folder during + * {@code preprocessOpenAPI}. The generated class has a single type parameter + * {@code } and mirrors the non-slot properties of the first matched schema.
  • + *
+ */ +public final class GenericSubstitutionSupport { + + private static 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 root output folder for this generator run. + * Used as the base path when writing Mode B source files directly. + */ + String outputFolder(); + + /** 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. + * Not used by this class currently, exposed for future extensibility. + */ + List supportingFiles(); + + /** + * Returns the file extension for generated source files, without the leading dot. + * {@code "java"} for Java; {@code "kt"} for Kotlin. + */ + String fileExtension(); + } + + // ========================================================================= + // 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<>(); + + /** Tracks which Mode B class names have already had their source file written. */ + private final Set generatedModeB = new HashSet<>(); + + // ========================================================================= + // Configuration setters + // ========================================================================= + + public void addPattern(GenericPatternConfig cfg) { + patterns.add(cfg); + } + + public void setDiscoverGenericPatterns(boolean v) { + this.discoverGenericPatterns = v; + } + + // ========================================================================= + // 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; + } + + 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: generate source file and add import mapping + String fullPath = ctx.outputFolder() + File.separator + configPath + + File.separator + className + "." + ext; + String fqn = ctx.getConfigPackage() + "." + className; + ctx.importMapping().putIfAbsent(className, fqn); + + if (!generatedModeB.contains(className)) { + generatedModeB.add(className); + String source = "kt".equals(ext) + ? buildKotlinSource(inst, ctx.getConfigPackage()) + : buildJavaSource(inst, ctx.getConfigPackage()); + writeSourceFile(fullPath, source); + LOGGER.info("GenericSubstitutionSupport: generated Mode B class '{}' at {}", + className, fullPath); + } + } 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}.

+ * + *

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 typeArg = ctx.toModelName(inst.firstTypeArg()); + String newType = inst.genericClassName + "<" + typeArg + ">"; + + op.returnType = newType; + op.returnBaseType = inst.genericClassName; + op.returnContainer = null; // generic wrapper is not a container + + op.imports.add(inst.genericClassName); + op.imports.add(typeArg); + if (ctx.getAnnotationLibrary() == AnnotationLibrary.NONE) { + op.imports.remove(inst.schemaName); + } + + LOGGER.info("GenericSubstitutionSupport: operation '{}': replacing return type '{}' with '{}'", + op.operationId, oldType, newType); + } + + // ========================================================================= + // Lifecycle 3: suppressGenericSchemas (called from postProcessAllModels) + // ========================================================================= + + /** + * Removes concrete generic-instance schemas (e.g. {@code UserResponse}, + * {@code PetResponse}) from the model map when {@code annotationLibrary=none}. + * + *

When annotation libraries are active, {@code @ApiResponse} and {@code @Schema} + * annotations in the generated code reference concrete schema classes, so they must + * be kept. Only when {@code annotationLibrary=none} is it safe to suppress them.

+ * + * @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()) { + return objs; + } + if (ctx.getAnnotationLibrary() != AnnotationLibrary.NONE) { + LOGGER.info("GenericSubstitutionSupport: keeping generic-instance schemas " + + "(annotationLibrary={}) — @ApiResponse annotations reference them", + ctx.getAnnotationLibrary().toCliOptValue()); + return objs; + } + + for (Map.Entry entry + : instanceRegistry.entrySet()) { + String schemaName = entry.getKey(); + GenericSchemaScanUtils.GenericInstance inst = entry.getValue(); + if (objs.remove(schemaName) != null) { + LOGGER.info("GenericSubstitutionSupport: suppressing model '{}' → {}{}", + schemaName, inst.genericClassName, "<" + inst.firstTypeArg() + ">"); + } + } + return objs; + } + + // ========================================================================= + // Mode B source generation — Java + // ========================================================================= + + /** + * Generates a Java POJO source for the generic class described by {@code instance}. + * The class has a single type parameter {@code }. + * + *

Slot properties are typed {@code T} (or {@code List} for array slots); + * fixed properties use their resolved Java types.

+ */ + String buildJavaSource(GenericSchemaScanUtils.GenericInstance instance, String packageName) { + StringBuilder sb = new StringBuilder(); + sb.append("package ").append(packageName).append(";\n\n"); + + // Determine imports + boolean needsList = instance.properties.stream().anyMatch(p -> p.isArray); + if (needsList) { + sb.append("import java.util.List;\n"); + sb.append("\n"); + } + + sb.append("/**\n"); + sb.append(" * Generic class generated by openapi-generator from schema pattern '") + .append(instance.genericClassName).append("'.\n"); + sb.append(" * Type parameter {@code T} is the varying domain type.\n"); + sb.append(" */\n"); + sb.append("public class ").append(instance.genericClassName).append(" {\n\n"); + + // Fields + for (GenericSchemaScanUtils.GenericProperty prop : instance.properties) { + String javaType = toJavaType(prop); + sb.append(" private ").append(javaType).append(" ").append(prop.name).append(";\n"); + } + sb.append("\n"); + + // No-args constructor + sb.append(" public ").append(instance.genericClassName).append("() {}\n\n"); + + // Getters and setters + for (GenericSchemaScanUtils.GenericProperty prop : instance.properties) { + String javaType = toJavaType(prop); + String capitalName = capitalize(prop.name); + sb.append(" public ").append(javaType).append(" get").append(capitalName) + .append("() { return ").append(prop.name).append("; }\n\n"); + sb.append(" public ").append(instance.genericClassName).append(" set") + .append(capitalName).append("(").append(javaType).append(" ").append(prop.name) + .append(") { this.").append(prop.name).append(" = ").append(prop.name) + .append("; return this; }\n\n"); + } + + sb.append("}\n"); + return sb.toString(); + } + + // ========================================================================= + // Mode B source generation — Kotlin + // ========================================================================= + + /** + * Generates a Kotlin data-class source for the generic class described by + * {@code instance}. The class has a single type parameter {@code }. + */ + String buildKotlinSource(GenericSchemaScanUtils.GenericInstance instance, String packageName) { + StringBuilder sb = new StringBuilder(); + sb.append("package ").append(packageName).append("\n\n"); + + sb.append("/**\n"); + sb.append(" * Generic class generated by openapi-generator from schema pattern '") + .append(instance.genericClassName).append("'.\n"); + sb.append(" * Type parameter [T] is the varying domain type.\n"); + sb.append(" */\n"); + sb.append("data class ").append(instance.genericClassName).append("(\n"); + + List props = instance.properties; + for (int i = 0; i < props.size(); i++) { + GenericSchemaScanUtils.GenericProperty prop = props.get(i); + String kotlinType = toKotlinType(prop); + boolean isLast = (i == props.size() - 1); + sb.append(" val ").append(prop.name).append(": ").append(kotlinType); + if (!prop.required) { + sb.append("? = null"); + } + if (!isLast) sb.append(","); + sb.append("\n"); + } + sb.append(")\n"); + return sb.toString(); + } + + // ========================================================================= + // Type mapping helpers + // ========================================================================= + + private static String toJavaType(GenericSchemaScanUtils.GenericProperty prop) { + if (prop.typeParam != null) { + return prop.isArray ? "List" : "T"; + } + 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" : "T"; + } + 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"; + } + } + + // ========================================================================= + // File writing helper + // ========================================================================= + + private static void writeSourceFile(String fullPath, String content) { + try { + Path path = Paths.get(fullPath); + Files.createDirectories(path.getParent()); + Files.write(path, content.getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + LOGGER.error("GenericSubstitutionSupport: failed to write Mode B source file '{}': {}", + fullPath, e.getMessage(), e); + } + } + + // ========================================================================= + // Utilities + // ========================================================================= + + private static String capitalize(String s) { + if (s == null || s.isEmpty()) return s; + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } + + /** + * 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 153aef1d5165..8fd9131aa0d8 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 @@ -54,7 +54,8 @@ import static org.openapitools.codegen.utils.StringUtils.camelize; 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); @@ -104,6 +105,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"; @@ -169,11 +172,35 @@ 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"; } + @Setter private boolean companionObject = false; @Getter @Setter @@ -189,20 +216,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(); @@ -294,15 +307,23 @@ 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. Only applies when library=spring-boot or spring-declarative-http-interface.", - 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); supportedLibraries.put(SPRING_BOOT, "Spring-boot Server application."); supportedLibraries.put(SPRING_CLOUD_LIBRARY, @@ -734,19 +755,42 @@ 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) && (library.equals(SPRING_BOOT) || library.equals(SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY))) { 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"); + 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"); } @@ -1026,144 +1070,21 @@ 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); - - 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)); - } - } + // Build pageable annotations, add Pageable imports, and remove page/size/sort params. + pageableSupport.processPageableAnnotations(codegenOperation, this, "[", "]"); // 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); - } - } + pageableSupport.substituteReturnType(codegenOperation, this); + + // Replace operation return types for generic schema patterns (genericPatterns feature). + genericSubstitutionSupport.substituteReturnType(codegenOperation, this); return codegenOperation; } @@ -1177,56 +1098,9 @@ 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")); - } - } - - 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"); - } - } - - 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")); - } - } + pageableSupport.preprocessOpenAPI(openAPI, this, SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY, "kt"); - if ((SPRING_BOOT.equals(library) || SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY.equals(library)) && 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()); - } - } + genericSubstitutionSupport.preprocessOpenAPI(openAPI, this); if (!additionalProperties.containsKey(TITLE)) { // The purpose of the title is for: @@ -1298,7 +1172,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 @@ -1332,46 +1206,12 @@ public void postProcessModelProperty(CodegenModel model, CodegenProperty propert public Map postProcessAllModels(Map objs) { objs = super.postProcessAllModels(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()); - } + if (pageableSupport.isSubstituteGenericPagedModel()) { + objs = pageableSupport.suppressPagedModels(objs, this); } + objs = genericSubstitutionSupport.suppressGenericSchemas(objs, this); + return objs; } 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 fea10a09014f..5a30e3c52e0f 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 @@ -58,7 +58,8 @@ import static org.openapitools.codegen.utils.StringUtils.camelize; 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"; @@ -115,6 +116,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"; @Getter public enum RequestMappingMode { @@ -190,21 +193,28 @@ 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; - - // 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"; + + 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"; } public SpringCodegen() { super(); @@ -362,25 +372,36 @@ 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. Only applies when library=spring-boot or spring-http-interface.", - 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)); } @@ -600,6 +621,29 @@ 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"); + 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"); @@ -845,56 +889,9 @@ 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"); - } - } - - 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")); - } - } - - if ((SPRING_BOOT.equals(library) || SPRING_HTTP_INTERFACE.equals(library)) && 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()); - } - } + genericSubstitutionSupport.preprocessOpenAPI(openAPI, this); /* * TODO the following logic should not need anymore in OAS 3.0 if @@ -1219,22 +1216,8 @@ 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); - } - } - } - } + // 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 // this allows to use a custom Pageable schema without importing Spring Pageable. @@ -1249,68 +1232,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); } @@ -1366,32 +1290,10 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation // 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); - } - } + pageableSupport.substituteReturnType(codegenOperation, this); + + // Replace operation return types for generic schema patterns (genericPatterns feature). + genericSubstitutionSupport.substituteReturnType(codegenOperation, this); return codegenOperation; } @@ -1447,45 +1349,9 @@ 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 = pageableSupport.suppressPagedModels(objs, this); + + objs = genericSubstitutionSupport.suppressGenericSchemas(objs, this); return objs; } 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..75cc9ab778ac --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableSupport.java @@ -0,0 +1,416 @@ +/* + * 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.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.*; +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.

+ */ +public final class SpringPageableSupport { + + private static 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)) + && 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()); + } + } + } + + /** + * 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 + * @return {@code true} if the operation was (or was already) marked as paginated + */ + public boolean autoDetectPagination(Operation operation, String library) { + if (!SpringCodegen.SPRING_BOOT.equals(library) || !autoXSpringPaginated) { + return false; + } + if (operation.getExtensions() != null + && Boolean.FALSE.equals(operation.getExtensions().get("x-spring-paginated"))) { + return false; + } + 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); + return true; + } + } + return false; + } + + /** + * 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())) { + 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); + 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); + } + } + + /** + * Replaces the operation's return type with {@code PagedModel} when the return type + * is a detected paged-model schema. + * + *

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

+ * + * @param codegenOperation the codegen operation whose return type may be replaced + * @param ctx callback access to the generator's state + */ + public void substituteReturnType(CodegenOperation codegenOperation, Context ctx) { + if (!substituteGenericPagedModel || pagedModelRegistry.isEmpty() + || codegenOperation.returnBaseType == null) { + return; + } + PagedModelScanUtils.DetectedPagedModel detected = + pagedModelRegistry.get(codegenOperation.returnBaseType); + if (detected == null) { + return; + } + String oldType = codegenOperation.returnType; + // Run through toModelName so that schemaMappings (e.g. User → com.example.MyUser) + // are honoured: the mapped name is used both in the type arg and for import resolution. + String itemType = ctx.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; + codegenOperation.imports.add(itemType); + codegenOperation.imports.add(pagedModelClassName); + if (ctx.getAnnotationLibrary() == AnnotationLibrary.NONE) { + codegenOperation.imports.remove(detected.schemaName); + } + LOGGER.info("substituteGenericPagedModel: operation '{}': replacing return type '{}' with {}<{}>", + codegenOperation.operationId, oldType, pagedModelClassName, itemType); + } + + /** + * Suppresses detected paged-model schemas (and their orphaned metadata schemas) from + * the model map when {@code annotationLibrary=none}. + * + *

When annotations are generated the paged-model schemas are still referenced by + * {@code @ApiResponse}, so they must be kept.

+ * + * @param objs the full model map, as received by {@code postProcessAllModels} + * @param ctx callback access to the generator's state + * @return the (possibly mutated) model map + */ + public Map suppressPagedModels(Map objs, Context ctx) { + if (!substituteGenericPagedModel || pagedModelRegistry.isEmpty()) { + return objs; + } + if (ctx.getAnnotationLibrary() != AnnotationLibrary.NONE) { + LOGGER.info("substituteGenericPagedModel: keeping paged-model schemas (annotationLibrary={}) " + + "— @ApiResponse annotations reference them", + ctx.getAnnotationLibrary().toCliOptValue()); + return objs; + } + + Set metaSchemasToCheck = new HashSet<>(); + for (PagedModelScanUtils.DetectedPagedModel detected : pagedModelRegistry.values()) { + if (detected.metaSchemaName != null) { + metaSchemasToCheck.add(detected.metaSchemaName); + } + } + // Remove paged schemas first so that reference checks below reflect 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 schema still references them. + 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); + } + } + return objs; + } +} 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 041953da7680..bace13666f5a 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,6 +50,7 @@ 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.List; import java.util.Map; @@ -7479,4 +7480,177 @@ private Map springHttpInterfacePagedModelProps() { props.put(SpringCodegen.SUBSTITUTE_GENERIC_PAGED_MODEL, "true"); 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"); + + props.put(SpringCodegen.GENERIC_PATTERNS, Arrays.asList(responsePattern, pagePattern)); + 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 { + // Mode B generates a file directly to disk — verify via the output folder + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("spring") + .setAdditionalProperties(genericPatternsProps()) + .setValidateSpec(false) + .setInputSpec("src/test/resources/3_0/spring/petstore-generics.yaml") + .setOutputDir(output.getAbsolutePath()) + .setLibrary(SPRING_BOOT); + + ClientOptInput input = configurator.toClientOptInput(); + DefaultGenerator generator = new DefaultGenerator(); + generator.setGenerateMetadata(false); + generator.opts(input).generate(); + + // Mode B: "ApiResponse" simple name → written to configPackage directory + File apiResponseFile = new File(output, + "src/main/java/org/openapitools/configuration/ApiResponse.java"); + assertThat(apiResponseFile).exists(); + } + + @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"); + } } 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..73b3e64d0a91 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/GenericSchemaScanUtilsTest.java @@ -0,0 +1,814 @@ +/* + * 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 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; + } + + // ========================================================================= + // 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_returnsNull() { + Schema allOf = pageSchemaAllOf("Pet"); + assertThat(GenericSchemaScanUtils.buildStructuralFingerprint(allOf)).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_allOfSchemas_excludedFromClustering() { + // allOf schemas cannot be fingerprinted + 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()); + + // allOf schemas return null fingerprint — they won't cluster + 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(); + } + + // ========================================================================= + // 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(); + } +} 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-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..36f18bddc8f3 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics.yaml @@ -0,0 +1,400 @@ +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: 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' + + /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 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 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..612bb24199c9 --- /dev/null +++ b/samples/server/petstore/springboot-generics/.openapi-generator/FILES @@ -0,0 +1,18 @@ +.openapi-generator-ignore +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/SearchApi.java +src/main/java/org/openapitools/api/VendorApi.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/Order.java +src/main/java/org/openapitools/model/PageMeta.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 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..f7962df3e243 --- /dev/null +++ b/samples/server/petstore/springboot-generics/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.22.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..6f0e4d68c914 --- /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.22.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.22.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..de61ba8ef624 --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/PageApi.java @@ -0,0 +1,66 @@ +/* + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.openapitools.api; + +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.22.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_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..72570f6ffd5b --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ResponseApi.java @@ -0,0 +1,79 @@ +/* + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.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.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.22.0-SNAPSHOT") +@Validated +@RequestMapping("${openapi.openAPIPetstoreGenericsTest.base-path:}") +public interface ResponseApi { + + 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/SearchApi.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/SearchApi.java new file mode 100644 index 000000000000..e24b34e4dbbd --- /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.22.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.22.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..6e792f03a4d9 --- /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.22.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.22.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..a8ccd88c5641 --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/configuration/ApiResponse.java @@ -0,0 +1,27 @@ +package org.openapitools.configuration; + +/** + * Generic class generated by openapi-generator from schema pattern 'ApiResponse'. + * Type parameter {@code T} is the varying domain type. + */ +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/model/LogEntry.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/LogEntry.java new file mode 100644 index 000000000000..7dcca9e8f9ac --- /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.22.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..f74943ce4d22 --- /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.22.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..740f3e20940d --- /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.22.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..3be327776898 --- /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.22.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/Order.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/Order.java new file mode 100644 index 000000000000..8e624cc9548a --- /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.22.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/PageMeta.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/PageMeta.java new file mode 100644 index 000000000000..9522ec3aed2b --- /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.22.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/Pet.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/Pet.java new file mode 100644 index 000000000000..e62a1ac11ba9 --- /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.22.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..af0b7a14306c --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/SearchResult.java @@ -0,0 +1,178 @@ +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.22.0-SNAPSHOT") +public class SearchResult implements Serializable { + + private static final long serialVersionUID = 1L; + + private @Nullable String query; + + private @Nullable Long totalHits; + + @Valid + private List results = new ArrayList<>(); + + @Valid + 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..91efc317af2f --- /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.22.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 "); + } +} + From 88a7ec58acbc14b5c326f49085262ae46328618d Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 24 Apr 2026 00:40:29 +0200 Subject: [PATCH 02/43] detect generics v2 --- .../openapitools/codegen/CodegenConfig.java | 10 + .../openapitools/codegen/DefaultCodegen.java | 5 + .../codegen/DefaultGenerator.java | 1 + .../languages/GenericSubstitutionSupport.java | 177 ++++++------------ .../languages/KotlinSpringServerCodegen.java | 5 + .../codegen/languages/SpringCodegen.java | 5 + .../JavaSpring/genericClass.mustache | 26 +++ .../kotlin-spring/genericClass.mustache | 15 ++ .../java/spring/SpringCodegenTest.java | 25 +-- .../.openapi-generator/FILES | 2 +- .../configuration/ApiResponse.java | 25 ++- 11 files changed, 149 insertions(+), 147 deletions(-) create mode 100644 modules/openapi-generator/src/main/resources/JavaSpring/genericClass.mustache create mode 100644 modules/openapi-generator/src/main/resources/kotlin-spring/genericClass.mustache 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/DefaultCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java index a1f0fe6246de..db9fbeff0573 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 @@ -1038,6 +1038,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..f2957a69b673 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,7 @@ private void generateSupportingFiles(List files, Map bundl shouldGenerate = supportingFilesToGenerate.contains(support.getDestinationFilename()); } + 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/languages/GenericSubstitutionSupport.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericSubstitutionSupport.java index f434961c6c27..999c27973fb3 100644 --- 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 @@ -26,13 +26,7 @@ import org.slf4j.LoggerFactory; import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.*; -import java.util.stream.Collectors; /** * Stateful delegate that centralises all generic-schema substitution logic usable by @@ -55,10 +49,13 @@ *
    *
  • 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 .java} or - * {@code .kt} source file is written directly to the output folder during - * {@code preprocessOpenAPI}. The generated class has a single type parameter - * {@code } and mirrors the non-slot properties of the first matched schema.
  • + *
  • 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.
  • *
*/ public final class GenericSubstitutionSupport { @@ -81,12 +78,8 @@ public interface Context { String getSourceFolder(); /** - * Returns the root output folder for this generator run. - * Used as the base path when writing Mode B source files directly. + * Returns the active annotation library. */ - String outputFolder(); - - /** Returns the active annotation library. */ AnnotationLibrary getAnnotationLibrary(); /** Converts an unqualified schema name to a codegen model name. */ @@ -100,7 +93,8 @@ public interface Context { /** * Returns the generator's mutable {@code supportingFiles} list. - * Not used by this class currently, exposed for future extensibility. + * Mode B classes are registered here as {@link SupportingFile} entries during + * {@link #preprocessOpenAPI}. */ List supportingFiles(); @@ -126,8 +120,12 @@ public interface Context { private final Map instanceRegistry = new LinkedHashMap<>(); - /** Tracks which Mode B class names have already had their source file written. */ - private final Set generatedModeB = new HashSet<>(); + /** + * 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 @@ -214,20 +212,16 @@ public void preprocessOpenAPI(OpenAPI openAPI, Context ctx) { processedGenericClasses.put(className, inst); if (inst.generateClass) { - // Mode B: generate source file and add import mapping - String fullPath = ctx.outputFolder() + File.separator + configPath - + File.separator + className + "." + ext; + // Mode B: register a mustache-based supporting file and add import mapping String fqn = ctx.getConfigPackage() + "." + className; ctx.importMapping().putIfAbsent(className, fqn); - if (!generatedModeB.contains(className)) { - generatedModeB.add(className); - String source = "kt".equals(ext) - ? buildKotlinSource(inst, ctx.getConfigPackage()) - : buildJavaSource(inst, ctx.getConfigPackage()); - writeSourceFile(fullPath, source); - LOGGER.info("GenericSubstitutionSupport: generated Mode B class '{}' at {}", - className, fullPath); + 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 @@ -318,93 +312,59 @@ public Map suppressGenericSchemas(Map objs } // ========================================================================= - // Mode B source generation — Java + // prepareSupportingFile — per-file bundle injection // ========================================================================= /** - * Generates a Java POJO source for the generic class described by {@code instance}. - * The class has a single type parameter {@code }. + * 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.

* - *

Slot properties are typed {@code T} (or {@code List} for array slots); - * fixed properties use their resolved Java types.

+ * @param bundle the shared data bundle; will have {@code "genericClassDef"} added for Mode B files + * @param support the supporting file about to be rendered */ - String buildJavaSource(GenericSchemaScanUtils.GenericInstance instance, String packageName) { - StringBuilder sb = new StringBuilder(); - sb.append("package ").append(packageName).append(";\n\n"); - - // Determine imports - boolean needsList = instance.properties.stream().anyMatch(p -> p.isArray); - if (needsList) { - sb.append("import java.util.List;\n"); - sb.append("\n"); - } - - sb.append("/**\n"); - sb.append(" * Generic class generated by openapi-generator from schema pattern '") - .append(instance.genericClassName).append("'.\n"); - sb.append(" * Type parameter {@code T} is the varying domain type.\n"); - sb.append(" */\n"); - sb.append("public class ").append(instance.genericClassName).append(" {\n\n"); - - // Fields - for (GenericSchemaScanUtils.GenericProperty prop : instance.properties) { - String javaType = toJavaType(prop); - sb.append(" private ").append(javaType).append(" ").append(prop.name).append(";\n"); + public void prepareSupportingFile(Map bundle, SupportingFile support) { + if (!"genericClass.mustache".equals(support.getTemplateFile())) { + return; } - sb.append("\n"); - - // No-args constructor - sb.append(" public ").append(instance.genericClassName).append("() {}\n\n"); - - // Getters and setters - for (GenericSchemaScanUtils.GenericProperty prop : instance.properties) { - String javaType = toJavaType(prop); - String capitalName = capitalize(prop.name); - sb.append(" public ").append(javaType).append(" get").append(capitalName) - .append("() { return ").append(prop.name).append("; }\n\n"); - sb.append(" public ").append(instance.genericClassName).append(" set") - .append(capitalName).append("(").append(javaType).append(" ").append(prop.name) - .append(") { this.").append(prop.name).append(" = ").append(prop.name) - .append("; return this; }\n\n"); + String dest = support.getDestinationFilename(); + int dot = dest.lastIndexOf('.'); + if (dot < 0) return; + String className = dest.substring(0, dot); + Map classData = modeBBundleData.get(className); + if (classData != null) { + bundle.put("genericClassDef", classData); } - - sb.append("}\n"); - return sb.toString(); } // ========================================================================= - // Mode B source generation — Kotlin + // Mode B template bundle data // ========================================================================= /** - * Generates a Kotlin data-class source for the generic class described by - * {@code instance}. The class has a single type parameter {@code }. + * Builds the data map injected into the bundle for a Mode B class template. + * Contains {@code className}, {@code needsList}, and a {@code properties} list. */ - String buildKotlinSource(GenericSchemaScanUtils.GenericInstance instance, String packageName) { - StringBuilder sb = new StringBuilder(); - sb.append("package ").append(packageName).append("\n\n"); - - sb.append("/**\n"); - sb.append(" * Generic class generated by openapi-generator from schema pattern '") - .append(instance.genericClassName).append("'.\n"); - sb.append(" * Type parameter [T] is the varying domain type.\n"); - sb.append(" */\n"); - sb.append("data class ").append(instance.genericClassName).append("(\n"); - - List props = instance.properties; - for (int i = 0; i < props.size(); i++) { - GenericSchemaScanUtils.GenericProperty prop = props.get(i); - String kotlinType = toKotlinType(prop); - boolean isLast = (i == props.size() - 1); - sb.append(" val ").append(prop.name).append(": ").append(kotlinType); - if (!prop.required) { - sb.append("? = null"); - } - if (!isLast) sb.append(","); - sb.append("\n"); + 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); + + 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); } - sb.append(")\n"); - return sb.toString(); + data.put("properties", propMaps); + return data; } // ========================================================================= @@ -447,21 +407,6 @@ private static String toKotlinType(GenericSchemaScanUtils.GenericProperty prop) } } - // ========================================================================= - // File writing helper - // ========================================================================= - - private static void writeSourceFile(String fullPath, String content) { - try { - Path path = Paths.get(fullPath); - Files.createDirectories(path.getParent()); - Files.write(path, content.getBytes(StandardCharsets.UTF_8)); - } catch (IOException e) { - LOGGER.error("GenericSubstitutionSupport: failed to write Mode B source file '{}': {}", - fullPath, e.getMessage(), e); - } - } - // ========================================================================= // Utilities // ========================================================================= 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 8fd9131aa0d8..66121afe526e 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 @@ -1215,6 +1215,11 @@ public Map postProcessAllModels(Map objs) return objs; } + @Override + public void prepareSupportingFile(Map bundle, SupportingFile file) { + genericSubstitutionSupport.prepareSupportingFile(bundle, file); + } + @Override public ModelsMap postProcessModelsEnum(ModelsMap objs) { objs = super.postProcessModelsEnum(objs); 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 5a30e3c52e0f..ef2e88317f40 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 @@ -1356,6 +1356,11 @@ public Map postProcessAllModels(Map objs) return objs; } + @Override + public void prepareSupportingFile(Map bundle, SupportingFile file) { + genericSubstitutionSupport.prepareSupportingFile(bundle, file); + } + @Override public ModelsMap postProcessModelsEnum(ModelsMap objs) { objs = super.postProcessModelsEnum(objs); 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..807e9cd194a9 --- /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 parameter {@code T} is the varying domain type. + * + *

To use your own class instead, supply a fully-qualified class name via + * {@code importMappings.{{className}}} in the generator config. + */ +public class {{className}} { +{{#properties}} + private {{javaType}} {{name}}; +{{/properties}} + public {{className}}() {} +{{#properties}} + public {{javaType}} get{{capitalName}}() { return {{name}}; } + + public {{className}} set{{capitalName}}({{javaType}} {{name}}) { + this.{{name}} = {{name}}; + return this; + } +{{/properties}} +} +{{/genericClassDef}} 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..c36d57f5012f --- /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 parameter [T] is the varying domain type. + * + * To use your own class instead, supply a fully-qualified class name via + * `importMappings.{{className}}` in the generator config. + */ +data class {{className}}( +{{#properties}} + val {{name}}: {{kotlinType}}{{^required}}? = null{{/required}}, +{{/properties}} +) +{{/genericClassDef}} 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 bace13666f5a..9efa4653484b 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 @@ -7565,27 +7565,12 @@ public void genericPatterns_keepsNonMatchedSchemas() throws IOException { @Test public void genericPatterns_modeBGeneratesClassFile() throws IOException { - // Mode B generates a file directly to disk — verify via the output folder - File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); - output.deleteOnExit(); - - final CodegenConfigurator configurator = new CodegenConfigurator() - .setGeneratorName("spring") - .setAdditionalProperties(genericPatternsProps()) - .setValidateSpec(false) - .setInputSpec("src/test/resources/3_0/spring/petstore-generics.yaml") - .setOutputDir(output.getAbsolutePath()) - .setLibrary(SPRING_BOOT); - - ClientOptInput input = configurator.toClientOptInput(); - DefaultGenerator generator = new DefaultGenerator(); - generator.setGenerateMetadata(false); - generator.opts(input).generate(); + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-generics.yaml", SPRING_BOOT, + genericPatternsProps()); - // Mode B: "ApiResponse" simple name → written to configPackage directory - File apiResponseFile = new File(output, - "src/main/java/org/openapitools/configuration/ApiResponse.java"); - assertThat(apiResponseFile).exists(); + // Mode B: "ApiResponse" simple name → registered as SupportingFile, must appear in generate() output + assertThat(files).containsKey("ApiResponse.java"); } @Test diff --git a/samples/server/petstore/springboot-generics/.openapi-generator/FILES b/samples/server/petstore/springboot-generics/.openapi-generator/FILES index 612bb24199c9..0105c195f93f 100644 --- a/samples/server/petstore/springboot-generics/.openapi-generator/FILES +++ b/samples/server/petstore/springboot-generics/.openapi-generator/FILES @@ -1,4 +1,3 @@ -.openapi-generator-ignore README.md pom.xml src/main/java/org/openapitools/api/ApiUtil.java @@ -7,6 +6,7 @@ src/main/java/org/openapitools/api/PageApi.java src/main/java/org/openapitools/api/ResponseApi.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/model/LogEntry.java src/main/java/org/openapitools/model/LogEntryData.java src/main/java/org/openapitools/model/MetricsEntry.java 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 index a8ccd88c5641..52a849e30a84 100644 --- 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 @@ -1,27 +1,32 @@ package org.openapitools.configuration; - /** * Generic class generated by openapi-generator from schema pattern 'ApiResponse'. * Type parameter {@code T} is the varying domain type. + * + *

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 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 ApiResponse setStatus(String status) { + this.status = status; + return this; + } public String getMessage() { return message; } - public ApiResponse setMessage(String message) { this.message = message; return this; } - + public ApiResponse setMessage(String message) { + this.message = message; + return this; + } } From cc3172ce4ea79a8ade186903a45d092ee017fbb8 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 24 Apr 2026 00:59:20 +0200 Subject: [PATCH 03/43] multi generics --- bin/configs/spring-boot-generics.yaml | 5 + .../languages/GenericPatternConfig.java | 39 +++- .../languages/GenericSchemaScanUtils.java | 141 +++++++++---- .../languages/GenericSubstitutionSupport.java | 33 ++- .../languages/KotlinSpringServerCodegen.java | 7 + .../codegen/languages/SpringCodegen.java | 7 + .../JavaSpring/genericClass.mustache | 6 +- .../kotlin-spring/genericClass.mustache | 4 +- .../java/spring/SpringCodegenTest.java | 81 +++++++- .../languages/GenericSchemaScanUtilsTest.java | 192 ++++++++++++++++++ .../3_0/spring/petstore-generics.yaml | 108 ++++++++++ .../.openapi-generator/FILES | 4 + .../java/org/openapitools/api/ResultApi.java | 63 ++++++ .../configuration/ApiResponse.java | 2 +- .../openapitools/configuration/Result.java | 32 +++ .../org/openapitools/model/PaymentError.java | 143 +++++++++++++ .../openapitools/model/ValidationError.java | 143 +++++++++++++ 17 files changed, 944 insertions(+), 66 deletions(-) create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ResultApi.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/configuration/Result.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/PaymentError.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/ValidationError.java diff --git a/bin/configs/spring-boot-generics.yaml b/bin/configs/spring-boot-generics.yaml index aad9e38a9d70..ce22ceb1ef1a 100644 --- a/bin/configs/spring-boot-generics.yaml +++ b/bin/configs/spring-boot-generics.yaml @@ -22,4 +22,9 @@ additionalProperties: - suffix: Page genericClass: org.springframework.data.domain.Page slotArray: content + - suffix: ErrorResult + genericClass: Result + slots: + data: T + error: E discoverGenericPatterns: "true" 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 index 2487a20f0b63..ff87372ffa2b 100644 --- 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 @@ -16,16 +16,19 @@ 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.

* - *

The {@link #slot} / {@link #slotArray} field names identify which property of the matched - * schema holds the type parameter {@code T}.

+ *

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 (in a generator config file)

+ *

Example YAML config (single type param)

*
{@code
  * additionalProperties:
  *   genericPatterns:
@@ -37,6 +40,17 @@
  *       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 @@ -45,8 +59,8 @@ * 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 has one type parameter {@code T} - * and mirrors the non-slot properties of the matched schemas.
  • + * {@code configPackage} folder. The generated class mirrors the non-slot properties + * of the matched schemas and declares all configured type parameters. *
*/ public class GenericPatternConfig { @@ -78,16 +92,24 @@ public class GenericPatternConfig { /** * 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}. + * 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}. + * 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. */ @@ -96,11 +118,12 @@ public GenericPatternConfig() {} 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 + "}"; + + ", 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 index 5604e5191c0b..957c570d15ca 100644 --- 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 @@ -47,8 +47,16 @@ public final class GenericSchemaScanUtils { private static final Logger LOGGER = 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 // ========================================================================= @@ -119,27 +127,34 @@ public static final class GenericInstance { public final boolean generateClass; /** * Maps slot property name to resolved type-argument schema name. - * E.g. {@code {"data" -> "User"}} or {@code {"content" -> "Pet"}}. - * For the current single-type-parameter implementation this map has exactly one entry. + * E.g. {@code {"data" -> "User"}} (single-param) or + * {@code {"data" -> "User", "error" -> "ValidationError"}} (multi-param). */ public final Map typeArgs; /** - * The name of the slot property (the key in {@link #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 slot property is an array property ({@code slotArray}), meaning the + * 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 the slot property having - * {@code typeParam="T"}. Used for Mode B class generation. + * 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; @@ -147,6 +162,7 @@ public GenericInstance(String schemaName, String genericClassName, String generi 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); @@ -230,10 +246,15 @@ public static List scanVendorExtensions(OpenAPI openAPI) { continue; } - // Identify slot property name and whether it's an array slot + // 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())) { @@ -246,13 +267,13 @@ public static List scanVendorExtensions(OpenAPI openAPI) { ? genericClassValue.substring(genericClassValue.lastIndexOf('.') + 1) : genericClassValue; - List properties = buildProperties(schema, openAPI, slotProperty, slotIsArray); + List properties = buildProperties(schema, openAPI, slotTypeParams); result.add(new GenericInstance( schemaName, genericClassName, isFqn ? genericClassValue : null, !isFqn, - typeArgs, slotProperty, slotIsArray, properties)); + typeArgs, slotTypeParams, slotProperty, slotIsArray, properties)); LOGGER.debug("GenericSchemaScanUtils Tier1: schema '{}' → {}{}", schemaName, genericClassName, @@ -313,35 +334,67 @@ public static List scanWithPatterns(OpenAPI openAPI, continue; } - // Determine slot and whether it's an array slot - String slotName = null; - boolean slotIsArray = false; - String typeArgSchemaName = null; + // 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); - if (pattern.slot != null && !pattern.slot.isEmpty()) { - // Expect a $ref property - String ref = findRefInProperties(props, pattern.slot, 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, openAPI); if (ref != null) { - slotName = pattern.slot; - slotIsArray = false; - typeArgSchemaName = extractSchemaNameFromRef(ref); + typeArgs.put(slotPropName, extractSchemaNameFromRef(ref)); + slotTypeParams.put(slotPropName, typeParamName); + if (primarySlotName == null) { + primarySlotName = slotPropName; + primarySlotIsArray = false; + } + continue; } - } else if (pattern.slotArray != null && !pattern.slotArray.isEmpty()) { - // Expect an array with items.$ref, handling allOf form too - String ref = findArrayItemRefInProperties(props, schema, pattern.slotArray, openAPI); - if (ref != null) { - slotName = pattern.slotArray; - slotIsArray = true; - typeArgSchemaName = extractSchemaNameFromRef(ref); + + // 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; } - } - if (slotName == null || typeArgSchemaName == null) { LOGGER.debug("GenericSchemaScanUtils Tier2: schema '{}' matched pattern '{}' by name " - + "but slot '{}' / slotArray '{}' not found or not a $ref — skipping", - schemaName, pattern, pattern.slot, pattern.slotArray); + + "but slot '{}' not found or not a $ref — skipping", + schemaName, pattern, slotPropName); + allSlotsFound = false; + break; + } + + if (!allSlotsFound || primarySlotName == null) { continue; } @@ -350,20 +403,17 @@ public static List scanWithPatterns(OpenAPI openAPI, ? pattern.genericClass.substring(pattern.genericClass.lastIndexOf('.') + 1) : pattern.genericClass; - Map typeArgs = new LinkedHashMap<>(); - typeArgs.put(slotName, typeArgSchemaName); - - List properties = buildProperties(schema, openAPI, slotName, slotIsArray); + List properties = buildProperties(schema, openAPI, slotTypeParams); result.add(new GenericInstance( schemaName, genericClassName, isFqn ? pattern.genericClass : null, !isFqn, - typeArgs, slotName, slotIsArray, properties)); + typeArgs, slotTypeParams, primarySlotName, primarySlotIsArray, properties)); LOGGER.debug("GenericSchemaScanUtils Tier2: schema '{}' matched pattern '{}' → {}<{}>", schemaName, pattern.suffix != null ? ("suffix=" + pattern.suffix) : ("prefix=" + pattern.prefix), - genericClassName, typeArgSchemaName); + genericClassName, typeArgs.values()); break; // first matching pattern wins } } @@ -547,12 +597,13 @@ private static String extractArrayItemRef(Schema schema) { // ========================================================================= /** - * Builds the full property list for a matched schema, marking the slot property with - * {@code typeParam="T"}. + * 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, - String slotName, boolean slotIsArray) { + Map slotTypeParams) { List result = new ArrayList<>(); Set required = schema.getRequired() != null ? new HashSet<>(schema.getRequired()) : Collections.emptySet(); @@ -565,12 +616,14 @@ private static List buildProperties(Schema schema, OpenAPI o Schema propSchema = (Schema) entry.getValue(); boolean isRequired = required.contains(name); - if (name.equals(slotName)) { - // Slot property - String format = slotIsArray && propSchema.getItems() != null + 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, slotIsArray ? "array" : "$ref", - null, "T", format, slotIsArray, isRequired)); + result.add(new GenericProperty(name, isSlotArray ? "array" : "$ref", + null, typeParam, format, isSlotArray, isRequired)); } else { result.add(buildNonSlotProperty(name, propSchema, isRequired, openAPI)); } 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 index 999c27973fb3..ecf6bc71109f 100644 --- 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 @@ -255,15 +255,23 @@ public void substituteReturnType(CodegenOperation op, Context ctx) { } String oldType = op.returnType; - String typeArg = ctx.toModelName(inst.firstTypeArg()); - String newType = inst.genericClassName + "<" + typeArg + ">"; + + // Build type args string from all slots in order + StringBuilder typeArgsBuilder = new StringBuilder(); + for (String slotProp : inst.slotTypeParams.keySet()) { + if (typeArgsBuilder.length() > 0) typeArgsBuilder.append(", "); + typeArgsBuilder.append(ctx.toModelName(inst.typeArgs.get(slotProp))); + } + String newType = inst.genericClassName + "<" + typeArgsBuilder + ">"; op.returnType = newType; op.returnBaseType = inst.genericClassName; op.returnContainer = null; // generic wrapper is not a container op.imports.add(inst.genericClassName); - op.imports.add(typeArg); + for (String resolvedSchema : inst.typeArgs.values()) { + op.imports.add(ctx.toModelName(resolvedSchema)); + } if (ctx.getAnnotationLibrary() == AnnotationLibrary.NONE) { op.imports.remove(inst.schemaName); } @@ -304,8 +312,8 @@ public Map suppressGenericSchemas(Map objs String schemaName = entry.getKey(); GenericSchemaScanUtils.GenericInstance inst = entry.getValue(); if (objs.remove(schemaName) != null) { - LOGGER.info("GenericSubstitutionSupport: suppressing model '{}' → {}{}", - schemaName, inst.genericClassName, "<" + inst.firstTypeArg() + ">"); + LOGGER.info("GenericSubstitutionSupport: suppressing model '{}' → {}", + schemaName, inst.genericClassName + "<" + inst.typeArgs.values() + ">"); } } return objs; @@ -353,6 +361,17 @@ private Map buildBundleData(GenericSchemaScanUtils.GenericInstan 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<>(); @@ -373,7 +392,7 @@ private Map buildBundleData(GenericSchemaScanUtils.GenericInstan private static String toJavaType(GenericSchemaScanUtils.GenericProperty prop) { if (prop.typeParam != null) { - return prop.isArray ? "List" : "T"; + return prop.isArray ? "List<" + prop.typeParam + ">" : prop.typeParam; } switch (prop.openApiType) { case "$ref": return prop.refTarget != null ? prop.refTarget : "Object"; @@ -391,7 +410,7 @@ private static String toJavaType(GenericSchemaScanUtils.GenericProperty prop) { private static String toKotlinType(GenericSchemaScanUtils.GenericProperty prop) { if (prop.typeParam != null) { - return prop.isArray ? "List" : "T"; + return prop.isArray ? "List<" + prop.typeParam + ">" : prop.typeParam; } switch (prop.openApiType) { case "$ref": return prop.refTarget != null ? prop.refTarget : "Any"; 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 66121afe526e..cbd9463a4aad 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 @@ -781,6 +781,13 @@ public void processOpts() { 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); } } 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 ef2e88317f40..be17d4b60233 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 @@ -633,6 +633,13 @@ public void processOpts() { 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); } } diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/genericClass.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/genericClass.mustache index 807e9cd194a9..d49316c9d6cd 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/genericClass.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/genericClass.mustache @@ -4,12 +4,12 @@ import java.util.List; {{/needsList}} /** * Generic class generated by openapi-generator from schema pattern '{{className}}'. - * Type parameter {@code T} is the varying domain type. + * 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}} { +public class {{className}}<{{#typeParams}}{{typeParam}}{{^isLast}}, {{/isLast}}{{/typeParams}}> { {{#properties}} private {{javaType}} {{name}}; {{/properties}} @@ -17,7 +17,7 @@ public class {{className}} { {{#properties}} public {{javaType}} get{{capitalName}}() { return {{name}}; } - public {{className}} set{{capitalName}}({{javaType}} {{name}}) { + public {{className}}<{{#typeParams}}{{typeParam}}{{^isLast}}, {{/isLast}}{{/typeParams}}> set{{capitalName}}({{javaType}} {{name}}) { this.{{name}} = {{name}}; return this; } diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/genericClass.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/genericClass.mustache index c36d57f5012f..0415624c0cc3 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/genericClass.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/genericClass.mustache @@ -2,12 +2,12 @@ package {{configPackage}} {{#genericClassDef}} /** * Generic class generated by openapi-generator from schema pattern '{{className}}'. - * Type parameter [T] is the varying domain type. + * 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}}( +data class {{className}}<{{#typeParams}}{{typeParam}}{{^isLast}}, {{/isLast}}{{/typeParams}}>( {{#properties}} val {{name}}: {{kotlinType}}{{^required}}? = null{{/required}}, {{/properties}} 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 9efa4653484b..3fa605a642b3 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 @@ -52,6 +52,7 @@ 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; @@ -7510,7 +7511,16 @@ private Map genericPatternsProps() { pagePattern.put("genericClass", "org.springframework.data.domain.Page"); pagePattern.put("slotArray", "content"); - props.put(SpringCodegen.GENERIC_PATTERNS, Arrays.asList(responsePattern, pagePattern)); + // 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; } @@ -7638,4 +7648,73 @@ public void genericPatterns_disabledByDefault_concreteSchemaGenerated() throws I 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"); + } } 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 index 73b3e64d0a91..04eac18f990d 100644 --- 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 @@ -151,6 +151,21 @@ private static GenericPatternConfig suffixSlotArrayPattern(String suffix, String 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 // ========================================================================= @@ -811,4 +826,181 @@ public void resolveProperties_flatSchema_returnsSchemaProperties() { 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/resources/3_0/spring/petstore-generics.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics.yaml index 36f18bddc8f3..a98a0a9a024f 100644 --- 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 @@ -24,6 +24,8 @@ tags: 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 @@ -148,6 +150,45 @@ paths: 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' + + /logs/latest: get: tags: [observability] @@ -342,6 +383,73 @@ components: 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 diff --git a/samples/server/petstore/springboot-generics/.openapi-generator/FILES b/samples/server/petstore/springboot-generics/.openapi-generator/FILES index 0105c195f93f..cc3aaae20115 100644 --- a/samples/server/petstore/springboot-generics/.openapi-generator/FILES +++ b/samples/server/petstore/springboot-generics/.openapi-generator/FILES @@ -4,15 +4,19 @@ 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/Order.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/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..25fa1e4a99f4 --- /dev/null +++ b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/api/ResultApi.java @@ -0,0 +1,63 @@ +/* + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.openapitools.api; + +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.22.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 + ); + +} 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 index 52a849e30a84..2af0dac59368 100644 --- 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 @@ -1,7 +1,7 @@ package org.openapitools.configuration; /** * Generic class generated by openapi-generator from schema pattern 'ApiResponse'. - * Type parameter {@code T} is the varying domain type. + * 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. 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/PaymentError.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/PaymentError.java new file mode 100644 index 000000000000..8708ee04fd35 --- /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.22.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/ValidationError.java b/samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/ValidationError.java new file mode 100644 index 000000000000..89a0acaf345f --- /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.22.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 "); + } +} + From 8cc4a46c8cc41fd19e9a4f5be054c258d1ed30bf Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 24 Apr 2026 01:04:41 +0200 Subject: [PATCH 04/43] multi generics - kotlin --- bin/configs/kotlin-spring-boot-generics.yaml | 30 +++ .../.openapi-generator-ignore | 23 ++ .../.openapi-generator/FILES | 30 +++ .../.openapi-generator/VERSION | 1 + .../kotlin-springboot-generics/README.md | 21 ++ .../build.gradle.kts | 48 ++++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43453 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + .../kotlin-springboot-generics/gradlew | 249 ++++++++++++++++++ .../kotlin-springboot-generics/gradlew.bat | 92 +++++++ .../kotlin-springboot-generics/pom.xml | 152 +++++++++++ .../settings.gradle | 15 ++ .../kotlin/org/openapitools/api/ApiUtil.kt | 19 ++ .../kotlin/org/openapitools/api/Exceptions.kt | 30 +++ .../org/openapitools/api/ObservabilityApi.kt | 50 ++++ .../kotlin/org/openapitools/api/PageApi.kt | 68 +++++ .../org/openapitools/api/ResponseApi.kt | 79 ++++++ .../kotlin/org/openapitools/api/ResultApi.kt | 68 +++++ .../kotlin/org/openapitools/api/SearchApi.kt | 52 ++++ .../kotlin/org/openapitools/api/VendorApi.kt | 51 ++++ .../openapitools/configuration/ApiResponse.kt | 13 + .../org/openapitools/configuration/Result.kt | 13 + .../kotlin/org/openapitools/model/LogEntry.kt | 36 +++ .../org/openapitools/model/LogEntryData.kt | 34 +++ .../org/openapitools/model/MetricsEntry.kt | 36 +++ .../openapitools/model/MetricsEntryData.kt | 34 +++ .../kotlin/org/openapitools/model/Order.kt | 34 +++ .../kotlin/org/openapitools/model/PageMeta.kt | 37 +++ .../org/openapitools/model/PaymentError.kt | 34 +++ .../main/kotlin/org/openapitools/model/Pet.kt | 34 +++ .../org/openapitools/model/SearchResult.kt | 37 +++ .../kotlin/org/openapitools/model/User.kt | 34 +++ .../org/openapitools/model/ValidationError.kt | 34 +++ 33 files changed, 1495 insertions(+) create mode 100644 bin/configs/kotlin-spring-boot-generics.yaml create mode 100644 samples/server/petstore/kotlin-springboot-generics/.openapi-generator-ignore create mode 100644 samples/server/petstore/kotlin-springboot-generics/.openapi-generator/FILES create mode 100644 samples/server/petstore/kotlin-springboot-generics/.openapi-generator/VERSION create mode 100644 samples/server/petstore/kotlin-springboot-generics/README.md create mode 100644 samples/server/petstore/kotlin-springboot-generics/build.gradle.kts create mode 100644 samples/server/petstore/kotlin-springboot-generics/gradle/wrapper/gradle-wrapper.jar create mode 100644 samples/server/petstore/kotlin-springboot-generics/gradle/wrapper/gradle-wrapper.properties create mode 100644 samples/server/petstore/kotlin-springboot-generics/gradlew create mode 100644 samples/server/petstore/kotlin-springboot-generics/gradlew.bat create mode 100644 samples/server/petstore/kotlin-springboot-generics/pom.xml create mode 100644 samples/server/petstore/kotlin-springboot-generics/settings.gradle create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ApiUtil.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/Exceptions.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ObservabilityApi.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/PageApi.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ResponseApi.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ResultApi.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/SearchApi.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/VendorApi.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/configuration/ApiResponse.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/configuration/Result.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/LogEntry.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/LogEntryData.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/MetricsEntry.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/MetricsEntryData.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/Order.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/PageMeta.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/PaymentError.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/Pet.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/SearchResult.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/User.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/ValidationError.kt 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/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..ec097a2f9dc1 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/.openapi-generator/FILES @@ -0,0 +1,30 @@ +.openapi-generator-ignore +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/Order.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..f7962df3e243 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.22.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..dff0fa211c8d --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/build.gradle.kts @@ -0,0 +1,48 @@ +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 0000000000000000000000000000000000000000..e6441136f3d4ba8a0da8d277868979cfbc8ad796 GIT binary patch literal 43453 zcma&N1CXTcmMvW9vTb(Rwr$&4wr$(C?dmSu>@vG-+vuvg^_??!{yS%8zW-#zn-LkA z5&1^$^{lnmUON?}LBF8_K|(?T0Ra(xUH{($5eN!MR#ZihR#HxkUPe+_R8Cn`RRs(P z_^*#_XlXmGv7!4;*Y%p4nw?{bNp@UZHv1?Um8r6)Fei3p@ClJn0ECfg1hkeuUU@Or zDaPa;U3fE=3L}DooL;8f;P0ipPt0Z~9P0)lbStMS)ag54=uL9ia-Lm3nh|@(Y?B`; zx_#arJIpXH!U{fbCbI^17}6Ri*H<>OLR%c|^mh8+)*h~K8Z!9)DPf zR2h?lbDZQ`p9P;&DQ4F0sur@TMa!Y}S8irn(%d-gi0*WxxCSk*A?3lGh=gcYN?FGl z7D=Js!i~0=u3rox^eO3i@$0=n{K1lPNU zwmfjRVmLOCRfe=seV&P*1Iq=^i`502keY8Uy-WNPwVNNtJFx?IwAyRPZo2Wo1+S(xF37LJZ~%i)kpFQ3Fw=mXfd@>%+)RpYQLnr}B~~zoof(JVm^^&f zxKV^+3D3$A1G;qh4gPVjhrC8e(VYUHv#dy^)(RoUFM?o%W-EHxufuWf(l*@-l+7vt z=l`qmR56K~F|v<^Pd*p~1_y^P0P^aPC##d8+HqX4IR1gu+7w#~TBFphJxF)T$2WEa zxa?H&6=Qe7d(#tha?_1uQys2KtHQ{)Qco)qwGjrdNL7thd^G5i8Os)CHqc>iOidS} z%nFEDdm=GXBw=yXe1W-ShHHFb?Cc70+$W~z_+}nAoHFYI1MV1wZegw*0y^tC*s%3h zhD3tN8b=Gv&rj}!SUM6|ajSPp*58KR7MPpI{oAJCtY~JECm)*m_x>AZEu>DFgUcby z1Qaw8lU4jZpQ_$;*7RME+gq1KySGG#Wql>aL~k9tLrSO()LWn*q&YxHEuzmwd1?aAtI zBJ>P=&$=l1efe1CDU;`Fd+_;&wI07?V0aAIgc(!{a z0Jg6Y=inXc3^n!U0Atk`iCFIQooHqcWhO(qrieUOW8X(x?(RD}iYDLMjSwffH2~tB z)oDgNBLB^AJBM1M^c5HdRx6fBfka`(LD-qrlh5jqH~);#nw|iyp)()xVYak3;Ybik z0j`(+69aK*B>)e_p%=wu8XC&9e{AO4c~O1U`5X9}?0mrd*m$_EUek{R?DNSh(=br# z#Q61gBzEpmy`$pA*6!87 zSDD+=@fTY7<4A?GLqpA?Pb2z$pbCc4B4zL{BeZ?F-8`s$?>*lXXtn*NC61>|*w7J* z$?!iB{6R-0=KFmyp1nnEmLsA-H0a6l+1uaH^g%c(p{iT&YFrbQ$&PRb8Up#X3@Zsk zD^^&LK~111%cqlP%!_gFNa^dTYT?rhkGl}5=fL{a`UViaXWI$k-UcHJwmaH1s=S$4 z%4)PdWJX;hh5UoK?6aWoyLxX&NhNRqKam7tcOkLh{%j3K^4Mgx1@i|Pi&}<^5>hs5 zm8?uOS>%)NzT(%PjVPGa?X%`N2TQCKbeH2l;cTnHiHppPSJ<7y-yEIiC!P*ikl&!B z%+?>VttCOQM@ShFguHVjxX^?mHX^hSaO_;pnyh^v9EumqSZTi+#f&_Vaija0Q-e*| z7ulQj6Fs*bbmsWp{`auM04gGwsYYdNNZcg|ph0OgD>7O}Asn7^Z=eI>`$2*v78;sj-}oMoEj&@)9+ycEOo92xSyY344^ z11Hb8^kdOvbf^GNAK++bYioknrpdN>+u8R?JxG=!2Kd9r=YWCOJYXYuM0cOq^FhEd zBg2puKy__7VT3-r*dG4c62Wgxi52EMCQ`bKgf*#*ou(D4-ZN$+mg&7$u!! z-^+Z%;-3IDwqZ|K=ah85OLwkO zKxNBh+4QHh)u9D?MFtpbl)us}9+V!D%w9jfAMYEb>%$A;u)rrI zuBudh;5PN}_6J_}l55P3l_)&RMlH{m!)ai-i$g)&*M`eN$XQMw{v^r@-125^RRCF0 z^2>|DxhQw(mtNEI2Kj(;KblC7x=JlK$@78`O~>V!`|1Lm-^JR$-5pUANAnb(5}B}JGjBsliK4& zk6y(;$e&h)lh2)L=bvZKbvh@>vLlreBdH8No2>$#%_Wp1U0N7Ank!6$dFSi#xzh|( zRi{Uw%-4W!{IXZ)fWx@XX6;&(m_F%c6~X8hx=BN1&q}*( zoaNjWabE{oUPb!Bt$eyd#$5j9rItB-h*5JiNi(v^e|XKAj*8(k<5-2$&ZBR5fF|JA z9&m4fbzNQnAU}r8ab>fFV%J0z5awe#UZ|bz?Ur)U9bCIKWEzi2%A+5CLqh?}K4JHi z4vtM;+uPsVz{Lfr;78W78gC;z*yTch~4YkLr&m-7%-xc ztw6Mh2d>_iO*$Rd8(-Cr1_V8EO1f*^@wRoSozS) zy1UoC@pruAaC8Z_7~_w4Q6n*&B0AjOmMWa;sIav&gu z|J5&|{=a@vR!~k-OjKEgPFCzcJ>#A1uL&7xTDn;{XBdeM}V=l3B8fE1--DHjSaxoSjNKEM9|U9#m2<3>n{Iuo`r3UZp;>GkT2YBNAh|b z^jTq-hJp(ebZh#Lk8hVBP%qXwv-@vbvoREX$TqRGTgEi$%_F9tZES@z8Bx}$#5eeG zk^UsLBH{bc2VBW)*EdS({yw=?qmevwi?BL6*=12k9zM5gJv1>y#ML4!)iiPzVaH9% zgSImetD@dam~e>{LvVh!phhzpW+iFvWpGT#CVE5TQ40n%F|p(sP5mXxna+Ev7PDwA zamaV4m*^~*xV+&p;W749xhb_X=$|LD;FHuB&JL5?*Y2-oIT(wYY2;73<^#46S~Gx| z^cez%V7x$81}UWqS13Gz80379Rj;6~WdiXWOSsdmzY39L;Hg3MH43o*y8ibNBBH`(av4|u;YPq%{R;IuYow<+GEsf@R?=@tT@!}?#>zIIn0CoyV!hq3mw zHj>OOjfJM3F{RG#6ujzo?y32m^tgSXf@v=J$ELdJ+=5j|=F-~hP$G&}tDZsZE?5rX ztGj`!S>)CFmdkccxM9eGIcGnS2AfK#gXwj%esuIBNJQP1WV~b~+D7PJTmWGTSDrR` zEAu4B8l>NPuhsk5a`rReSya2nfV1EK01+G!x8aBdTs3Io$u5!6n6KX%uv@DxAp3F@{4UYg4SWJtQ-W~0MDb|j-$lwVn znAm*Pl!?Ps&3wO=R115RWKb*JKoexo*)uhhHBncEDMSVa_PyA>k{Zm2(wMQ(5NM3# z)jkza|GoWEQo4^s*wE(gHz?Xsg4`}HUAcs42cM1-qq_=+=!Gk^y710j=66(cSWqUe zklbm8+zB_syQv5A2rj!Vbw8;|$@C!vfNmNV!yJIWDQ>{+2x zKjuFX`~~HKG~^6h5FntRpnnHt=D&rq0>IJ9#F0eM)Y-)GpRjiN7gkA8wvnG#K=q{q z9dBn8_~wm4J<3J_vl|9H{7q6u2A!cW{bp#r*-f{gOV^e=8S{nc1DxMHFwuM$;aVI^ zz6A*}m8N-&x8;aunp1w7_vtB*pa+OYBw=TMc6QK=mbA-|Cf* zvyh8D4LRJImooUaSb7t*fVfih<97Gf@VE0|z>NcBwBQze);Rh!k3K_sfunToZY;f2 z^HmC4KjHRVg+eKYj;PRN^|E0>Gj_zagfRbrki68I^#~6-HaHg3BUW%+clM1xQEdPYt_g<2K+z!$>*$9nQ>; zf9Bei{?zY^-e{q_*|W#2rJG`2fy@{%6u0i_VEWTq$*(ZN37|8lFFFt)nCG({r!q#9 z5VK_kkSJ3?zOH)OezMT{!YkCuSSn!K#-Rhl$uUM(bq*jY? zi1xbMVthJ`E>d>(f3)~fozjg^@eheMF6<)I`oeJYx4*+M&%c9VArn(OM-wp%M<-`x z7sLP1&3^%Nld9Dhm@$3f2}87!quhI@nwd@3~fZl_3LYW-B?Ia>ui`ELg z&Qfe!7m6ze=mZ`Ia9$z|ARSw|IdMpooY4YiPN8K z4B(ts3p%2i(Td=tgEHX z0UQ_>URBtG+-?0E;E7Ld^dyZ;jjw0}XZ(}-QzC6+NN=40oDb2^v!L1g9xRvE#@IBR zO!b-2N7wVfLV;mhEaXQ9XAU+>=XVA6f&T4Z-@AX!leJ8obP^P^wP0aICND?~w&NykJ#54x3_@r7IDMdRNy4Hh;h*!u(Ol(#0bJdwEo$5437-UBjQ+j=Ic>Q2z` zJNDf0yO6@mr6y1#n3)s(W|$iE_i8r@Gd@!DWDqZ7J&~gAm1#~maIGJ1sls^gxL9LLG_NhU!pTGty!TbhzQnu)I*S^54U6Yu%ZeCg`R>Q zhBv$n5j0v%O_j{QYWG!R9W?5_b&67KB$t}&e2LdMvd(PxN6Ir!H4>PNlerpBL>Zvyy!yw z-SOo8caEpDt(}|gKPBd$qND5#a5nju^O>V&;f890?yEOfkSG^HQVmEbM3Ugzu+UtH zC(INPDdraBN?P%kE;*Ae%Wto&sgw(crfZ#Qy(<4nk;S|hD3j{IQRI6Yq|f^basLY; z-HB&Je%Gg}Jt@={_C{L$!RM;$$|iD6vu#3w?v?*;&()uB|I-XqEKqZPS!reW9JkLewLb!70T7n`i!gNtb1%vN- zySZj{8-1>6E%H&=V}LM#xmt`J3XQoaD|@XygXjdZ1+P77-=;=eYpoEQ01B@L*a(uW zrZeZz?HJsw_4g0vhUgkg@VF8<-X$B8pOqCuWAl28uB|@r`19DTUQQsb^pfqB6QtiT z*`_UZ`fT}vtUY#%sq2{rchyfu*pCg;uec2$-$N_xgjZcoumE5vSI{+s@iLWoz^Mf; zuI8kDP{!XY6OP~q5}%1&L}CtfH^N<3o4L@J@zg1-mt{9L`s^z$Vgb|mr{@WiwAqKg zp#t-lhrU>F8o0s1q_9y`gQNf~Vb!F%70f}$>i7o4ho$`uciNf=xgJ>&!gSt0g;M>*x4-`U)ysFW&Vs^Vk6m%?iuWU+o&m(2Jm26Y(3%TL; zA7T)BP{WS!&xmxNw%J=$MPfn(9*^*TV;$JwRy8Zl*yUZi8jWYF>==j~&S|Xinsb%c z2?B+kpet*muEW7@AzjBA^wAJBY8i|#C{WtO_or&Nj2{=6JTTX05}|H>N2B|Wf!*3_ z7hW*j6p3TvpghEc6-wufFiY!%-GvOx*bZrhZu+7?iSrZL5q9}igiF^*R3%DE4aCHZ zqu>xS8LkW+Auv%z-<1Xs92u23R$nk@Pk}MU5!gT|c7vGlEA%G^2th&Q*zfg%-D^=f z&J_}jskj|Q;73NP4<4k*Y%pXPU2Thoqr+5uH1yEYM|VtBPW6lXaetokD0u z9qVek6Q&wk)tFbQ8(^HGf3Wp16gKmr>G;#G(HRBx?F`9AIRboK+;OfHaLJ(P>IP0w zyTbTkx_THEOs%Q&aPrxbZrJlio+hCC_HK<4%f3ZoSAyG7Dn`=X=&h@m*|UYO-4Hq0 z-Bq&+Ie!S##4A6OGoC~>ZW`Y5J)*ouaFl_e9GA*VSL!O_@xGiBw!AF}1{tB)z(w%c zS1Hmrb9OC8>0a_$BzeiN?rkPLc9%&;1CZW*4}CDDNr2gcl_3z+WC15&H1Zc2{o~i) z)LLW=WQ{?ricmC`G1GfJ0Yp4Dy~Ba;j6ZV4r{8xRs`13{dD!xXmr^Aga|C=iSmor% z8hi|pTXH)5Yf&v~exp3o+sY4B^^b*eYkkCYl*T{*=-0HniSA_1F53eCb{x~1k3*`W zr~};p1A`k{1DV9=UPnLDgz{aJH=-LQo<5%+Em!DNN252xwIf*wF_zS^!(XSm(9eoj z=*dXG&n0>)_)N5oc6v!>-bd(2ragD8O=M|wGW z!xJQS<)u70m&6OmrF0WSsr@I%T*c#Qo#Ha4d3COcX+9}hM5!7JIGF>7<~C(Ear^Sn zm^ZFkV6~Ula6+8S?oOROOA6$C&q&dp`>oR-2Ym3(HT@O7Sd5c~+kjrmM)YmgPH*tL zX+znN>`tv;5eOfX?h{AuX^LK~V#gPCu=)Tigtq9&?7Xh$qN|%A$?V*v=&-2F$zTUv z`C#WyIrChS5|Kgm_GeudCFf;)!WH7FI60j^0o#65o6`w*S7R@)88n$1nrgU(oU0M9 zx+EuMkC>(4j1;m6NoGqEkpJYJ?vc|B zOlwT3t&UgL!pX_P*6g36`ZXQ; z9~Cv}ANFnJGp(;ZhS(@FT;3e)0)Kp;h^x;$*xZn*k0U6-&FwI=uOGaODdrsp-!K$Ac32^c{+FhI-HkYd5v=`PGsg%6I`4d9Jy)uW0y%) zm&j^9WBAp*P8#kGJUhB!L?a%h$hJgQrx!6KCB_TRo%9{t0J7KW8!o1B!NC)VGLM5! zpZy5Jc{`r{1e(jd%jsG7k%I+m#CGS*BPA65ZVW~fLYw0dA-H_}O zrkGFL&P1PG9p2(%QiEWm6x;U-U&I#;Em$nx-_I^wtgw3xUPVVu zqSuKnx&dIT-XT+T10p;yjo1Y)z(x1fb8Dzfn8e yu?e%!_ptzGB|8GrCfu%p?(_ zQccdaaVK$5bz;*rnyK{_SQYM>;aES6Qs^lj9lEs6_J+%nIiuQC*fN;z8md>r_~Mfl zU%p5Dt_YT>gQqfr@`cR!$NWr~+`CZb%dn;WtzrAOI>P_JtsB76PYe*<%H(y>qx-`Kq!X_; z<{RpAqYhE=L1r*M)gNF3B8r(<%8mo*SR2hu zccLRZwGARt)Hlo1euqTyM>^!HK*!Q2P;4UYrysje@;(<|$&%vQekbn|0Ruu_Io(w4#%p6ld2Yp7tlA`Y$cciThP zKzNGIMPXX%&Ud0uQh!uQZz|FB`4KGD?3!ND?wQt6!n*f4EmCoJUh&b?;B{|lxs#F- z31~HQ`SF4x$&v00@(P+j1pAaj5!s`)b2RDBp*PB=2IB>oBF!*6vwr7Dp%zpAx*dPr zb@Zjq^XjN?O4QcZ*O+8>)|HlrR>oD*?WQl5ri3R#2?*W6iJ>>kH%KnnME&TT@ZzrHS$Q%LC?n|e>V+D+8D zYc4)QddFz7I8#}y#Wj6>4P%34dZH~OUDb?uP%-E zwjXM(?Sg~1!|wI(RVuxbu)-rH+O=igSho_pDCw(c6b=P zKk4ATlB?bj9+HHlh<_!&z0rx13K3ZrAR8W)!@Y}o`?a*JJsD+twZIv`W)@Y?Amu_u zz``@-e2X}27$i(2=9rvIu5uTUOVhzwu%mNazS|lZb&PT;XE2|B&W1>=B58#*!~D&) zfVmJGg8UdP*fx(>Cj^?yS^zH#o-$Q-*$SnK(ZVFkw+er=>N^7!)FtP3y~Xxnu^nzY zikgB>Nj0%;WOltWIob|}%lo?_C7<``a5hEkx&1ku$|)i>Rh6@3h*`slY=9U}(Ql_< zaNG*J8vb&@zpdhAvv`?{=zDedJ23TD&Zg__snRAH4eh~^oawdYi6A3w8<Ozh@Kw)#bdktM^GVb zrG08?0bG?|NG+w^&JvD*7LAbjED{_Zkc`3H!My>0u5Q}m!+6VokMLXxl`Mkd=g&Xx z-a>m*#G3SLlhbKB!)tnzfWOBV;u;ftU}S!NdD5+YtOjLg?X}dl>7m^gOpihrf1;PY zvll&>dIuUGs{Qnd- zwIR3oIrct8Va^Tm0t#(bJD7c$Z7DO9*7NnRZorrSm`b`cxz>OIC;jSE3DO8`hX955ui`s%||YQtt2 z5DNA&pG-V+4oI2s*x^>-$6J?p=I>C|9wZF8z;VjR??Icg?1w2v5Me+FgAeGGa8(3S z4vg*$>zC-WIVZtJ7}o9{D-7d>zCe|z#<9>CFve-OPAYsneTb^JH!Enaza#j}^mXy1 z+ULn^10+rWLF6j2>Ya@@Kq?26>AqK{A_| zQKb*~F1>sE*=d?A?W7N2j?L09_7n+HGi{VY;MoTGr_)G9)ot$p!-UY5zZ2Xtbm=t z@dpPSGwgH=QtIcEulQNI>S-#ifbnO5EWkI;$A|pxJd885oM+ zGZ0_0gDvG8q2xebj+fbCHYfAXuZStH2j~|d^sBAzo46(K8n59+T6rzBwK)^rfPT+B zyIFw)9YC-V^rhtK`!3jrhmW-sTmM+tPH+;nwjL#-SjQPUZ53L@A>y*rt(#M(qsiB2 zx6B)dI}6Wlsw%bJ8h|(lhkJVogQZA&n{?Vgs6gNSXzuZpEyu*xySy8ro07QZ7Vk1!3tJphN_5V7qOiyK8p z#@jcDD8nmtYi1^l8ml;AF<#IPK?!pqf9D4moYk>d99Im}Jtwj6c#+A;f)CQ*f-hZ< z=p_T86jog%!p)D&5g9taSwYi&eP z#JuEK%+NULWus;0w32-SYFku#i}d~+{Pkho&^{;RxzP&0!RCm3-9K6`>KZpnzS6?L z^H^V*s!8<>x8bomvD%rh>Zp3>Db%kyin;qtl+jAv8Oo~1g~mqGAC&Qi_wy|xEt2iz zWAJEfTV%cl2Cs<1L&DLRVVH05EDq`pH7Oh7sR`NNkL%wi}8n>IXcO40hp+J+sC!W?!krJf!GJNE8uj zg-y~Ns-<~D?yqbzVRB}G>0A^f0!^N7l=$m0OdZuqAOQqLc zX?AEGr1Ht+inZ-Qiwnl@Z0qukd__a!C*CKuGdy5#nD7VUBM^6OCpxCa2A(X;e0&V4 zM&WR8+wErQ7UIc6LY~Q9x%Sn*Tn>>P`^t&idaOEnOd(Ufw#>NoR^1QdhJ8s`h^|R_ zXX`c5*O~Xdvh%q;7L!_!ohf$NfEBmCde|#uVZvEo>OfEq%+Ns7&_f$OR9xsihRpBb z+cjk8LyDm@U{YN>+r46?nn{7Gh(;WhFw6GAxtcKD+YWV?uge>;+q#Xx4!GpRkVZYu zzsF}1)7$?%s9g9CH=Zs+B%M_)+~*j3L0&Q9u7!|+T`^O{xE6qvAP?XWv9_MrZKdo& z%IyU)$Q95AB4!#hT!_dA>4e@zjOBD*Y=XjtMm)V|+IXzjuM;(l+8aA5#Kaz_$rR6! zj>#&^DidYD$nUY(D$mH`9eb|dtV0b{S>H6FBfq>t5`;OxA4Nn{J(+XihF(stSche7$es&~N$epi&PDM_N`As;*9D^L==2Q7Z2zD+CiU(|+-kL*VG+&9!Yb3LgPy?A zm7Z&^qRG_JIxK7-FBzZI3Q<;{`DIxtc48k> zc|0dmX;Z=W$+)qE)~`yn6MdoJ4co;%!`ddy+FV538Y)j(vg}5*k(WK)KWZ3WaOG!8 z!syGn=s{H$odtpqFrT#JGM*utN7B((abXnpDM6w56nhw}OY}0TiTG1#f*VFZr+^-g zbP10`$LPq_;PvrA1XXlyx2uM^mrjTzX}w{yuLo-cOClE8MMk47T25G8M!9Z5ypOSV zAJUBGEg5L2fY)ZGJb^E34R2zJ?}Vf>{~gB!8=5Z) z9y$>5c)=;o0HeHHSuE4U)#vG&KF|I%-cF6f$~pdYJWk_dD}iOA>iA$O$+4%@>JU08 zS`ep)$XLPJ+n0_i@PkF#ri6T8?ZeAot$6JIYHm&P6EB=BiaNY|aA$W0I+nz*zkz_z zkEru!tj!QUffq%)8y0y`T&`fuus-1p>=^hnBiBqD^hXrPs`PY9tU3m0np~rISY09> z`P3s=-kt_cYcxWd{de@}TwSqg*xVhp;E9zCsnXo6z z?f&Sv^U7n4`xr=mXle94HzOdN!2kB~4=%)u&N!+2;z6UYKUDqi-s6AZ!haB;@&B`? z_TRX0%@suz^TRdCb?!vNJYPY8L_}&07uySH9%W^Tc&1pia6y1q#?*Drf}GjGbPjBS zbOPcUY#*$3sL2x4v_i*Y=N7E$mR}J%|GUI(>WEr+28+V z%v5{#e!UF*6~G&%;l*q*$V?&r$Pp^sE^i-0$+RH3ERUUdQ0>rAq2(2QAbG}$y{de( z>{qD~GGuOk559Y@%$?N^1ApVL_a704>8OD%8Y%8B;FCt%AoPu8*D1 zLB5X>b}Syz81pn;xnB}%0FnwazlWfUV)Z-~rZg6~b z6!9J$EcE&sEbzcy?CI~=boWA&eeIa%z(7SE^qgVLz??1Vbc1*aRvc%Mri)AJaAG!p z$X!_9Ds;Zz)f+;%s&dRcJt2==P{^j3bf0M=nJd&xwUGlUFn?H=2W(*2I2Gdu zv!gYCwM10aeus)`RIZSrCK=&oKaO_Ry~D1B5!y0R=%!i2*KfXGYX&gNv_u+n9wiR5 z*e$Zjju&ODRW3phN925%S(jL+bCHv6rZtc?!*`1TyYXT6%Ju=|X;6D@lq$8T zW{Y|e39ioPez(pBH%k)HzFITXHvnD6hw^lIoUMA;qAJ^CU?top1fo@s7xT13Fvn1H z6JWa-6+FJF#x>~+A;D~;VDs26>^oH0EI`IYT2iagy23?nyJ==i{g4%HrAf1-*v zK1)~@&(KkwR7TL}L(A@C_S0G;-GMDy=MJn2$FP5s<%wC)4jC5PXoxrQBFZ_k0P{{s@sz+gX`-!=T8rcB(=7vW}^K6oLWMmp(rwDh}b zwaGGd>yEy6fHv%jM$yJXo5oMAQ>c9j`**}F?MCry;T@47@r?&sKHgVe$MCqk#Z_3S z1GZI~nOEN*P~+UaFGnj{{Jo@16`(qVNtbU>O0Hf57-P>x8Jikp=`s8xWs^dAJ9lCQ z)GFm+=OV%AMVqVATtN@|vp61VVAHRn87}%PC^RAzJ%JngmZTasWBAWsoAqBU+8L8u z4A&Pe?fmTm0?mK-BL9t+{y7o(7jm+RpOhL9KnY#E&qu^}B6=K_dB}*VlSEiC9fn)+V=J;OnN)Ta5v66ic1rG+dGAJ1 z1%Zb_+!$=tQ~lxQrzv3x#CPb?CekEkA}0MYSgx$Jdd}q8+R=ma$|&1a#)TQ=l$1tQ z=tL9&_^vJ)Pk}EDO-va`UCT1m#Uty1{v^A3P~83_#v^ozH}6*9mIjIr;t3Uv%@VeW zGL6(CwCUp)Jq%G0bIG%?{_*Y#5IHf*5M@wPo6A{$Um++Co$wLC=J1aoG93&T7Ho}P z=mGEPP7GbvoG!uD$k(H3A$Z))+i{Hy?QHdk>3xSBXR0j!11O^mEe9RHmw!pvzv?Ua~2_l2Yh~_!s1qS`|0~0)YsbHSz8!mG)WiJE| z2f($6TQtt6L_f~ApQYQKSb=`053LgrQq7G@98#igV>y#i==-nEjQ!XNu9 z~;mE+gtj4IDDNQJ~JVk5Ux6&LCSFL!y=>79kE9=V}J7tD==Ga+IW zX)r7>VZ9dY=V&}DR))xUoV!u(Z|%3ciQi_2jl}3=$Agc(`RPb z8kEBpvY>1FGQ9W$n>Cq=DIpski};nE)`p3IUw1Oz0|wxll^)4dq3;CCY@RyJgFgc# zKouFh!`?Xuo{IMz^xi-h=StCis_M7yq$u) z?XHvw*HP0VgR+KR6wI)jEMX|ssqYvSf*_3W8zVTQzD?3>H!#>InzpSO)@SC8q*ii- z%%h}_#0{4JG;Jm`4zg};BPTGkYamx$Xo#O~lBirRY)q=5M45n{GCfV7h9qwyu1NxOMoP4)jjZMxmT|IQQh0U7C$EbnMN<3)Kk?fFHYq$d|ICu>KbY_hO zTZM+uKHe(cIZfEqyzyYSUBZa8;Fcut-GN!HSA9ius`ltNebF46ZX_BbZNU}}ZOm{M2&nANL9@0qvih15(|`S~z}m&h!u4x~(%MAO$jHRWNfuxWF#B)E&g3ghSQ9|> z(MFaLQj)NE0lowyjvg8z0#m6FIuKE9lDO~Glg}nSb7`~^&#(Lw{}GVOS>U)m8bF}x zVjbXljBm34Cs-yM6TVusr+3kYFjr28STT3g056y3cH5Tmge~ASxBj z%|yb>$eF;WgrcOZf569sDZOVwoo%8>XO>XQOX1OyN9I-SQgrm;U;+#3OI(zrWyow3 zk==|{lt2xrQ%FIXOTejR>;wv(Pb8u8}BUpx?yd(Abh6? zsoO3VYWkeLnF43&@*#MQ9-i-d0t*xN-UEyNKeyNMHw|A(k(_6QKO=nKMCxD(W(Yop zsRQ)QeL4X3Lxp^L%wzi2-WVSsf61dqliPUM7srDB?Wm6Lzn0&{*}|IsKQW;02(Y&| zaTKv|`U(pSzuvR6Rduu$wzK_W-Y-7>7s?G$)U}&uK;<>vU}^^ns@Z!p+9?St1s)dG zK%y6xkPyyS1$~&6v{kl?Md6gwM|>mt6Upm>oa8RLD^8T{0?HC!Z>;(Bob7el(DV6x zi`I)$&E&ngwFS@bi4^xFLAn`=fzTC;aimE^!cMI2n@Vo%Ae-ne`RF((&5y6xsjjAZ zVguVoQ?Z9uk$2ON;ersE%PU*xGO@T*;j1BO5#TuZKEf(mB7|g7pcEA=nYJ{s3vlbg zd4-DUlD{*6o%Gc^N!Nptgay>j6E5;3psI+C3Q!1ZIbeCubW%w4pq9)MSDyB{HLm|k zxv-{$$A*pS@csolri$Ge<4VZ}e~78JOL-EVyrbxKra^d{?|NnPp86!q>t<&IP07?Z z^>~IK^k#OEKgRH+LjllZXk7iA>2cfH6+(e&9ku5poo~6y{GC5>(bRK7hwjiurqAiZ zg*DmtgY}v83IjE&AbiWgMyFbaRUPZ{lYiz$U^&Zt2YjG<%m((&_JUbZcfJ22(>bi5 z!J?<7AySj0JZ&<-qXX;mcV!f~>G=sB0KnjWca4}vrtunD^1TrpfeS^4dvFr!65knK zZh`d;*VOkPs4*-9kL>$GP0`(M!j~B;#x?Ba~&s6CopvO86oM?-? zOw#dIRc;6A6T?B`Qp%^<U5 z19x(ywSH$_N+Io!6;e?`tWaM$`=Db!gzx|lQ${DG!zb1Zl&|{kX0y6xvO1o z220r<-oaS^^R2pEyY;=Qllqpmue|5yI~D|iI!IGt@iod{Opz@*ml^w2bNs)p`M(Io z|E;;m*Xpjd9l)4G#KaWfV(t8YUn@A;nK^#xgv=LtnArX|vWQVuw3}B${h+frU2>9^ z!l6)!Uo4`5k`<<;E(ido7M6lKTgWezNLq>U*=uz&s=cc$1%>VrAeOoUtA|T6gO4>UNqsdK=NF*8|~*sl&wI=x9-EGiq*aqV!(VVXA57 zw9*o6Ir8Lj1npUXvlevtn(_+^X5rzdR>#(}4YcB9O50q97%rW2me5_L=%ffYPUSRc z!vv?Kv>dH994Qi>U(a<0KF6NH5b16enCp+mw^Hb3Xs1^tThFpz!3QuN#}KBbww`(h z7GO)1olDqy6?T$()R7y%NYx*B0k_2IBiZ14&8|JPFxeMF{vSTxF-Vi3+ZOI=Thq2} zyQgjYY1_7^ZQHh{?P))4+qUiQJLi1&{yE>h?~jU%tjdV0h|FENbM3X(KnJdPKc?~k zh=^Ixv*+smUll!DTWH!jrV*wSh*(mx0o6}1@JExzF(#9FXgmTXVoU+>kDe68N)dkQ zH#_98Zv$}lQwjKL@yBd;U(UD0UCl322=pav<=6g>03{O_3oKTq;9bLFX1ia*lw;#K zOiYDcBJf)82->83N_Y(J7Kr_3lE)hAu;)Q(nUVydv+l+nQ$?|%MWTy`t>{havFSQloHwiIkGK9YZ79^9?AZo0ZyQlVR#}lF%dn5n%xYksXf8gnBm=wO7g_^! zauQ-bH1Dc@3ItZ-9D_*pH}p!IG7j8A_o94#~>$LR|TFq zZ-b00*nuw|-5C2lJDCw&8p5N~Z1J&TrcyErds&!l3$eSz%`(*izc;-?HAFD9AHb-| z>)id`QCrzRws^9(#&=pIx9OEf2rmlob8sK&xPCWS+nD~qzU|qG6KwA{zbikcfQrdH z+ zQg>O<`K4L8rN7`GJB0*3<3`z({lWe#K!4AZLsI{%z#ja^OpfjU{!{)x0ZH~RB0W5X zTwN^w=|nA!4PEU2=LR05x~}|B&ZP?#pNgDMwD*ajI6oJqv!L81gu=KpqH22avXf0w zX3HjbCI!n9>l046)5rr5&v5ja!xkKK42zmqHzPx$9Nn_MZk`gLeSLgC=LFf;H1O#B zn=8|^1iRrujHfbgA+8i<9jaXc;CQBAmQvMGQPhFec2H1knCK2x!T`e6soyrqCamX% zTQ4dX_E*8so)E*TB$*io{$c6X)~{aWfaqdTh=xEeGvOAN9H&-t5tEE-qso<+C!2>+ zskX51H-H}#X{A75wqFe-J{?o8Bx|>fTBtl&tcbdR|132Ztqu5X0i-pisB-z8n71%q%>EF}yy5?z=Ve`}hVh{Drv1YWL zW=%ug_&chF11gDv3D6B)Tz5g54H0mDHNjuKZ+)CKFk4Z|$RD zfRuKLW`1B>B?*RUfVd0+u8h3r-{@fZ{k)c!93t1b0+Q9vOaRnEn1*IL>5Z4E4dZ!7 ztp4GP-^1d>8~LMeb}bW!(aAnB1tM_*la=Xx)q(I0Y@__Zd$!KYb8T2VBRw%e$iSdZ zkwdMwd}eV9q*;YvrBFTv1>1+}{H!JK2M*C|TNe$ZSA>UHKk);wz$(F$rXVc|sI^lD zV^?_J!3cLM;GJuBMbftbaRUs$;F}HDEDtIeHQ)^EJJ1F9FKJTGH<(Jj`phE6OuvE) zqK^K`;3S{Y#1M@8yRQwH`?kHMq4tHX#rJ>5lY3DM#o@or4&^_xtBC(|JpGTfrbGkA z2Tu+AyT^pHannww!4^!$5?@5v`LYy~T`qs7SYt$JgrY(w%C+IWA;ZkwEF)u5sDvOK zGk;G>Mh&elvXDcV69J_h02l&O;!{$({fng9Rlc3ID#tmB^FIG^w{HLUpF+iB`|

NnX)EH+Nua)3Y(c z&{(nX_ht=QbJ%DzAya}!&uNu!4V0xI)QE$SY__m)SAKcN0P(&JcoK*Lxr@P zY&P=}&B3*UWNlc|&$Oh{BEqwK2+N2U$4WB7Fd|aIal`FGANUa9E-O)!gV`((ZGCc$ zBJA|FFrlg~9OBp#f7aHodCe{6= zay$6vN~zj1ddMZ9gQ4p32(7wD?(dE>KA2;SOzXRmPBiBc6g`eOsy+pVcHu=;Yd8@{ zSGgXf@%sKKQz~;!J;|2fC@emm#^_rnO0esEn^QxXgJYd`#FPWOUU5b;9eMAF zZhfiZb|gk8aJIw*YLp4!*(=3l8Cp{(%p?ho22*vN9+5NLV0TTazNY$B5L6UKUrd$n zjbX%#m7&F#U?QNOBXkiiWB*_tk+H?N3`vg;1F-I+83{M2!8<^nydGr5XX}tC!10&e z7D36bLaB56WrjL&HiiMVtpff|K%|*{t*ltt^5ood{FOG0<>k&1h95qPio)2`eL${YAGIx(b4VN*~nKn6E~SIQUuRH zQ+5zP6jfnP$S0iJ@~t!Ai3o`X7biohli;E zT#yXyl{bojG@-TGZzpdVDXhbmF%F9+-^YSIv|MT1l3j zrxOFq>gd2%U}?6}8mIj?M zc077Zc9fq(-)4+gXv?Az26IO6eV`RAJz8e3)SC7~>%rlzDwySVx*q$ygTR5kW2ds- z!HBgcq0KON9*8Ff$X0wOq$`T7ml(@TF)VeoF}x1OttjuVHn3~sHrMB++}f7f9H%@f z=|kP_?#+fve@{0MlbkC9tyvQ_R?lRdRJ@$qcB(8*jyMyeME5ns6ypVI1Xm*Zr{DuS zZ!1)rQfa89c~;l~VkCiHI|PCBd`S*2RLNQM8!g9L6?n`^evQNEwfO@&JJRme+uopQX0%Jo zgd5G&#&{nX{o?TQwQvF1<^Cg3?2co;_06=~Hcb6~4XWpNFL!WU{+CK;>gH%|BLOh7@!hsa(>pNDAmpcuVO-?;Bic17R}^|6@8DahH)G z!EmhsfunLL|3b=M0MeK2vqZ|OqUqS8npxwge$w-4pFVXFq$_EKrZY?BuP@Az@(k`L z`ViQBSk`y+YwRT;&W| z2e3UfkCo^uTA4}Qmmtqs+nk#gNr2W4 zTH%hhErhB)pkXR{B!q5P3-OM+M;qu~f>}IjtF%>w{~K-0*jPVLl?Chz&zIdxp}bjx zStp&Iufr58FTQ36AHU)0+CmvaOpKF;W@sMTFpJ`j;3d)J_$tNQI^c<^1o<49Z(~K> z;EZTBaVT%14(bFw2ob@?JLQ2@(1pCdg3S%E4*dJ}dA*v}_a4_P(a`cHnBFJxNobAv zf&Zl-Yt*lhn-wjZsq<9v-IsXxAxMZ58C@e0!rzhJ+D@9^3~?~yllY^s$?&oNwyH!#~6x4gUrfxplCvK#!f z$viuszW>MFEcFL?>ux*((!L$;R?xc*myjRIjgnQX79@UPD$6Dz0jutM@7h_pq z0Zr)#O<^y_K6jfY^X%A-ip>P%3saX{!v;fxT-*0C_j4=UMH+Xth(XVkVGiiKE#f)q z%Jp=JT)uy{&}Iq2E*xr4YsJ5>w^=#-mRZ4vPXpI6q~1aFwi+lQcimO45V-JXP;>(Q zo={U`{=_JF`EQj87Wf}{Qy35s8r1*9Mxg({CvOt}?Vh9d&(}iI-quvs-rm~P;eRA@ zG5?1HO}puruc@S{YNAF3vmUc2B4!k*yi))<5BQmvd3tr}cIs#9)*AX>t`=~{f#Uz0 z0&Nk!7sSZwJe}=)-R^$0{yeS!V`Dh7w{w5rZ9ir!Z7Cd7dwZcK;BT#V0bzTt>;@Cl z#|#A!-IL6CZ@eHH!CG>OO8!%G8&8t4)Ro@}USB*k>oEUo0LsljsJ-%5Mo^MJF2I8- z#v7a5VdJ-Cd%(a+y6QwTmi+?f8Nxtm{g-+WGL>t;s#epv7ug>inqimZCVm!uT5Pf6 ziEgQt7^%xJf#!aPWbuC_3Nxfb&CFbQy!(8ANpkWLI4oSnH?Q3f?0k1t$3d+lkQs{~(>06l&v|MpcFsyAv zin6N!-;pggosR*vV=DO(#+}4ps|5$`udE%Kdmp?G7B#y%H`R|i8skKOd9Xzx8xgR$>Zo2R2Ytktq^w#ul4uicxW#{ zFjG_RNlBroV_n;a7U(KIpcp*{M~e~@>Q#Av90Jc5v%0c>egEdY4v3%|K1XvB{O_8G zkTWLC>OZKf;XguMH2-Pw{BKbFzaY;4v2seZV0>^7Q~d4O=AwaPhP3h|!hw5aqOtT@ z!SNz}$of**Bl3TK209@F=Tn1+mgZa8yh(Png%Zd6Mt}^NSjy)etQrF zme*llAW=N_8R*O~d2!apJnF%(JcN??=`$qs3Y+~xs>L9x`0^NIn!8mMRFA_tg`etw z3k{9JAjnl@ygIiJcNHTy02GMAvBVqEss&t2<2mnw!; zU`J)0>lWiqVqo|ex7!+@0i>B~BSU1A_0w#Ee+2pJx0BFiZ7RDHEvE*ptc9md(B{&+ zKE>TM)+Pd>HEmdJao7U@S>nL(qq*A)#eLOuIfAS@j`_sK0UEY6OAJJ-kOrHG zjHx`g!9j*_jRcJ%>CE9K2MVf?BUZKFHY?EpV6ai7sET-tqk=nDFh-(65rhjtlKEY% z@G&cQ<5BKatfdA1FKuB=i>CCC5(|9TMW%K~GbA4}80I5%B}(gck#Wlq@$nO3%@QP_ z8nvPkJFa|znk>V92cA!K1rKtr)skHEJD;k8P|R8RkCq1Rh^&}Evwa4BUJz2f!2=MH zo4j8Y$YL2313}H~F7@J7mh>u%556Hw0VUOz-Un@ZASCL)y8}4XXS`t1AC*^>PLwIc zUQok5PFS=*#)Z!3JZN&eZ6ZDP^-c@StY*t20JhCnbMxXf=LK#;`4KHEqMZ-Ly9KsS zI2VUJGY&PmdbM+iT)zek)#Qc#_i4uH43 z@T5SZBrhNCiK~~esjsO9!qBpaWK<`>!-`b71Y5ReXQ4AJU~T2Njri1CEp5oKw;Lnm)-Y@Z3sEY}XIgSy%xo=uek(kAAH5MsV$V3uTUsoTzxp_rF=tx zV07vlJNKtJhCu`b}*#m&5LV4TAE&%KtHViDAdv#c^x`J7bg z&N;#I2GkF@SIGht6p-V}`!F_~lCXjl1BdTLIjD2hH$J^YFN`7f{Q?OHPFEM$65^!u zNwkelo*5+$ZT|oQ%o%;rBX$+?xhvjb)SHgNHE_yP%wYkkvXHS{Bf$OiKJ5d1gI0j< zF6N}Aq=(WDo(J{e-uOecxPD>XZ@|u-tgTR<972`q8;&ZD!cep^@B5CaqFz|oU!iFj zU0;6fQX&~15E53EW&w1s9gQQ~Zk16X%6 zjG`j0yq}4deX2?Tr(03kg>C(!7a|b9qFI?jcE^Y>-VhudI@&LI6Qa}WQ>4H_!UVyF z((cm&!3gmq@;BD#5P~0;_2qgZhtJS|>WdtjY=q zLnHH~Fm!cxw|Z?Vw8*~?I$g#9j&uvgm7vPr#&iZgPP~v~BI4jOv;*OQ?jYJtzO<^y z7-#C={r7CO810!^s(MT!@@Vz_SVU)7VBi(e1%1rvS!?PTa}Uv`J!EP3s6Y!xUgM^8 z4f!fq<3Wer_#;u!5ECZ|^c1{|q_lh3m^9|nsMR1#Qm|?4Yp5~|er2?W^7~cl;_r4WSme_o68J9p03~Hc%X#VcX!xAu%1`R!dfGJCp zV*&m47>s^%Ib0~-2f$6oSgn3jg8m%UA;ArcdcRyM5;}|r;)?a^D*lel5C`V5G=c~k zy*w_&BfySOxE!(~PI$*dwG><+-%KT5p?whOUMA*k<9*gi#T{h3DAxzAPxN&Xws8o9Cp*`PA5>d9*Z-ynV# z9yY*1WR^D8|C%I@vo+d8r^pjJ$>eo|j>XiLWvTWLl(^;JHCsoPgem6PvegHb-OTf| zvTgsHSa;BkbG=(NgPO|CZu9gUCGr$8*EoH2_Z#^BnxF0yM~t`|9ws_xZ8X8iZYqh! zAh;HXJ)3P&)Q0(&F>!LN0g#bdbis-cQxyGn9Qgh`q+~49Fqd2epikEUw9caM%V6WgP)532RMRW}8gNS%V%Hx7apSz}tn@bQy!<=lbhmAH=FsMD?leawbnP5BWM0 z5{)@EEIYMu5;u)!+HQWhQ;D3_Cm_NADNeb-f56}<{41aYq8p4=93d=-=q0Yx#knGYfXVt z+kMxlus}t2T5FEyCN~!}90O_X@@PQpuy;kuGz@bWft%diBTx?d)_xWd_-(!LmVrh**oKg!1CNF&LX4{*j|) zIvjCR0I2UUuuEXh<9}oT_zT#jOrJAHNLFT~Ilh9hGJPI1<5`C-WA{tUYlyMeoy!+U zhA#=p!u1R7DNg9u4|QfED-2TuKI}>p#2P9--z;Bbf4Op*;Q9LCbO&aL2i<0O$ByoI z!9;Ght733FC>Pz>$_mw(F`zU?`m@>gE`9_p*=7o=7av`-&ifU(^)UU`Kg3Kw`h9-1 z6`e6+im=|m2v`pN(2dE%%n8YyQz;#3Q-|x`91z?gj68cMrHl}C25|6(_dIGk*8cA3 zRHB|Nwv{@sP4W+YZM)VKI>RlB`n=Oj~Rzx~M+Khz$N$45rLn6k1nvvD^&HtsMA4`s=MmuOJID@$s8Ph4E zAmSV^+s-z8cfv~Yd(40Sh4JG#F~aB>WFoX7ykaOr3JaJ&Lb49=B8Vk-SQT9%7TYhv z?-Pprt{|=Y5ZQ1?od|A<_IJU93|l4oAfBm?3-wk{O<8ea+`}u%(kub(LFo2zFtd?4 zwpN|2mBNywv+d^y_8#<$r>*5+$wRTCygFLcrwT(qc^n&@9r+}Kd_u@Ithz(6Qb4}A zWo_HdBj#V$VE#l6pD0a=NfB0l^6W^g`vm^sta>Tly?$E&{F?TTX~DsKF~poFfmN%2 z4x`Dc{u{Lkqz&y!33;X}weD}&;7p>xiI&ZUb1H9iD25a(gI|`|;G^NwJPv=1S5e)j z;U;`?n}jnY6rA{V^ zxTd{bK)Gi^odL3l989DQlN+Zs39Xe&otGeY(b5>rlIqfc7Ap4}EC?j<{M=hlH{1+d zw|c}}yx88_xQr`{98Z!d^FNH77=u(p-L{W6RvIn40f-BldeF-YD>p6#)(Qzf)lfZj z?3wAMtPPp>vMehkT`3gToPd%|D8~4`5WK{`#+}{L{jRUMt zrFz+O$C7y8$M&E4@+p+oV5c%uYzbqd2Y%SSgYy#xh4G3hQv>V*BnuKQhBa#=oZB~w{azUB+q%bRe_R^ z>fHBilnRTUfaJ201czL8^~Ix#+qOHSO)A|xWLqOxB$dT2W~)e-r9;bm=;p;RjYahB z*1hegN(VKK+ztr~h1}YP@6cfj{e#|sS`;3tJhIJK=tVJ-*h-5y9n*&cYCSdg#EHE# zSIx=r#qOaLJoVVf6v;(okg6?*L_55atl^W(gm^yjR?$GplNP>BZsBYEf_>wM0Lc;T zhf&gpzOWNxS>m+mN92N0{;4uw`P+9^*|-1~$uXpggj4- z^SFc4`uzj2OwdEVT@}Q`(^EcQ_5(ZtXTql*yGzdS&vrS_w>~~ra|Nb5abwf}Y!uq6R5f&6g2ge~2p(%c< z@O)cz%%rr4*cRJ5f`n@lvHNk@lE1a*96Kw6lJ~B-XfJW%?&-y?;E&?1AacU@`N`!O z6}V>8^%RZ7SQnZ-z$(jsX`amu*5Fj8g!3RTRwK^`2_QHe;_2y_n|6gSaGyPmI#kA0sYV<_qOZc#-2BO%hX)f$s-Z3xlI!ub z^;3ru11DA`4heAu%}HIXo&ctujzE2!6DIGE{?Zs>2}J+p&C$rc7gJC35gxhflorvsb%sGOxpuWhF)dL_&7&Z99=5M0b~Qa;Mo!j&Ti_kXW!86N%n= zSC@6Lw>UQ__F&+&Rzv?gscwAz8IP!n63>SP)^62(HK98nGjLY2*e^OwOq`3O|C92? z;TVhZ2SK%9AGW4ZavTB9?)mUbOoF`V7S=XM;#3EUpR+^oHtdV!GK^nXzCu>tpR|89 zdD{fnvCaN^^LL%amZ^}-E+214g&^56rpdc@yv0b<3}Ys?)f|fXN4oHf$six)-@<;W&&_kj z-B}M5U*1sb4)77aR=@%I?|Wkn-QJVuA96an25;~!gq(g1@O-5VGo7y&E_srxL6ZfS z*R%$gR}dyONgju*D&?geiSj7SZ@ftyA|}(*Y4KbvU!YLsi1EDQQCnb+-cM=K1io78o!v*);o<XwjaQH%)uIP&Zm?)Nfbfn;jIr z)d#!$gOe3QHp}2NBak@yYv3m(CPKkwI|{;d=gi552u?xj9ObCU^DJFQp4t4e1tPzM zvsRIGZ6VF+{6PvqsplMZWhz10YwS={?`~O0Ec$`-!klNUYtzWA^f9m7tkEzCy<_nS z=&<(awFeZvt51>@o_~>PLs05CY)$;}Oo$VDO)?l-{CS1Co=nxjqben*O1BR>#9`0^ zkwk^k-wcLCLGh|XLjdWv0_Hg54B&OzCE^3NCP}~OajK-LuRW53CkV~Su0U>zN%yQP zH8UH#W5P3-!ToO-2k&)}nFe`t+mdqCxxAHgcifup^gKpMObbox9LFK;LP3}0dP-UW z?Zo*^nrQ6*$FtZ(>kLCc2LY*|{!dUn$^RW~m9leoF|@Jy|M5p-G~j%+P0_#orRKf8 zvuu5<*XO!B?1E}-*SY~MOa$6c%2cM+xa8}_8x*aVn~57v&W(0mqN1W`5a7*VN{SUH zXz98DDyCnX2EPl-`Lesf`=AQT%YSDb`$%;(jUTrNen$NPJrlpPDP}prI>Ml!r6bCT;mjsg@X^#&<}CGf0JtR{Ecwd&)2zuhr#nqdgHj+g2n}GK9CHuwO zk>oZxy{vcOL)$8-}L^iVfJHAGfwN$prHjYV0ju}8%jWquw>}_W6j~m<}Jf!G?~r5&Rx)!9JNX!ts#SGe2HzobV5); zpj@&`cNcO&q+%*<%D7za|?m5qlmFK$=MJ_iv{aRs+BGVrs)98BlN^nMr{V_fcl_;jkzRju+c-y?gqBC_@J0dFLq-D9@VN&-`R9U;nv$Hg?>$oe4N&Ht$V_(JR3TG^! zzJsbQbi zFE6-{#9{G{+Z}ww!ycl*7rRdmU#_&|DqPfX3CR1I{Kk;bHwF6jh0opI`UV2W{*|nn zf_Y@%wW6APb&9RrbEN=PQRBEpM(N1w`81s=(xQj6 z-eO0k9=Al|>Ej|Mw&G`%q8e$2xVz1v4DXAi8G};R$y)ww638Y=9y$ZYFDM$}vzusg zUf+~BPX>(SjA|tgaFZr_e0{)+z9i6G#lgt=F_n$d=beAt0Sa0a7>z-?vcjl3e+W}+ z1&9=|vC=$co}-Zh*%3588G?v&U7%N1Qf-wNWJ)(v`iO5KHSkC5&g7CrKu8V}uQGcfcz zmBz#Lbqwqy#Z~UzHgOQ;Q-rPxrRNvl(&u6ts4~0=KkeS;zqURz%!-ERppmd%0v>iRlEf+H$yl{_8TMJzo0 z>n)`On|7=WQdsqhXI?#V{>+~}qt-cQbokEbgwV3QvSP7&hK4R{Z{aGHVS3;+h{|Hz z6$Js}_AJr383c_+6sNR|$qu6dqHXQTc6?(XWPCVZv=)D#6_;D_8P-=zOGEN5&?~8S zl5jQ?NL$c%O)*bOohdNwGIKM#jSAC?BVY={@A#c9GmX0=T(0G}xs`-%f3r=m6-cpK z!%waekyAvm9C3%>sixdZj+I(wQlbB4wv9xKI*T13DYG^T%}zZYJ|0$Oj^YtY+d$V$ zAVudSc-)FMl|54n=N{BnZTM|!>=bhaja?o7s+v1*U$!v!qQ%`T-6fBvmdPbVmro&d zk07TOp*KuxRUSTLRrBj{mjsnF8`d}rMViY8j`jo~Hp$fkv9F_g(jUo#Arp;Xw0M$~ zRIN!B22~$kx;QYmOkos@%|5k)!QypDMVe}1M9tZfkpXKGOxvKXB!=lo`p?|R1l=tA zp(1}c6T3Fwj_CPJwVsYtgeRKg?9?}%oRq0F+r+kdB=bFUdVDRPa;E~~>2$w}>O>v=?|e>#(-Lyx?nbg=ckJ#5U6;RT zNvHhXk$P}m9wSvFyU3}=7!y?Y z=fg$PbV8d7g25&-jOcs{%}wTDKm>!Vk);&rr;O1nvO0VrU&Q?TtYVU=ir`te8SLlS zKSNmV=+vF|ATGg`4$N1uS|n??f}C_4Sz!f|4Ly8#yTW-FBfvS48Tef|-46C(wEO_%pPhUC5$-~Y?!0vFZ^Gu`x=m7X99_?C-`|h zfmMM&Y@zdfitA@KPw4Mc(YHcY1)3*1xvW9V-r4n-9ZuBpFcf{yz+SR{ zo$ZSU_|fgwF~aakGr(9Be`~A|3)B=9`$M-TWKipq-NqRDRQc}ABo*s_5kV%doIX7LRLRau_gd@Rd_aLFXGSU+U?uAqh z8qusWWcvgQ&wu{|sRXmv?sl=xc<$6AR$+cl& zFNh5q1~kffG{3lDUdvEZu5c(aAG~+64FxdlfwY^*;JSS|m~CJusvi-!$XR`6@XtY2 znDHSz7}_Bx7zGq-^5{stTRy|I@N=>*y$zz>m^}^{d&~h;0kYiq8<^Wq7Dz0w31ShO^~LUfW6rfitR0(=3;Uue`Y%y@ex#eKPOW zO~V?)M#AeHB2kovn1v=n^D?2{2jhIQd9t|_Q+c|ZFaWt+r&#yrOu-!4pXAJuxM+Cx z*H&>eZ0v8Y`t}8{TV6smOj=__gFC=eah)mZt9gwz>>W$!>b3O;Rm^Ig*POZP8Rl0f zT~o=Nu1J|lO>}xX&#P58%Yl z83`HRs5#32Qm9mdCrMlV|NKNC+Z~ z9OB8xk5HJ>gBLi+m@(pvpw)1(OaVJKs*$Ou#@Knd#bk+V@y;YXT?)4eP9E5{J%KGtYinNYJUH9PU3A}66c>Xn zZ{Bn0<;8$WCOAL$^NqTjwM?5d=RHgw3!72WRo0c;+houoUA@HWLZM;^U$&sycWrFd zE7ekt9;kb0`lps{>R(}YnXlyGY}5pPd9zBpgXeJTY_jwaJGSJQC#-KJqmh-;ad&F- z-Y)E>!&`Rz!HtCz>%yOJ|v(u7P*I$jqEY3}(Z-orn4 zlI?CYKNl`6I){#2P1h)y(6?i;^z`N3bxTV%wNvQW+eu|x=kbj~s8rhCR*0H=iGkSj zk23lr9kr|p7#qKL=UjgO`@UnvzU)`&fI>1Qs7ubq{@+lK{hH* zvl6eSb9%yngRn^T<;jG1SVa)eA>T^XX=yUS@NCKpk?ovCW1D@!=@kn;l_BrG;hOTC z6K&H{<8K#dI(A+zw-MWxS+~{g$tI7|SfP$EYKxA}LlVO^sT#Oby^grkdZ^^lA}uEF zBSj$weBJG{+Bh@Yffzsw=HyChS(dtLE3i*}Zj@~!_T-Ay7z=B)+*~3|?w`Zd)Co2t zC&4DyB!o&YgSw+fJn6`sn$e)29`kUwAc+1MND7YjV%lO;H2}fNy>hD#=gT ze+-aFNpyKIoXY~Vq-}OWPBe?Rfu^{ps8>Xy%42r@RV#*QV~P83jdlFNgkPN=T|Kt7 zV*M`Rh*30&AWlb$;ae130e@}Tqi3zx2^JQHpM>j$6x`#{mu%tZlwx9Gj@Hc92IuY* zarmT|*d0E~vt6<+r?W^UW0&#U&)8B6+1+;k^2|FWBRP9?C4Rk)HAh&=AS8FS|NQaZ z2j!iZ)nbEyg4ZTp-zHwVlfLC~tXIrv(xrP8PAtR{*c;T24ycA-;auWsya-!kF~CWZ zw_uZ|%urXgUbc@x=L=_g@QJ@m#5beS@6W195Hn7>_}z@Xt{DIEA`A&V82bc^#!q8$ zFh?z_Vn|ozJ;NPd^5uu(9tspo8t%&-U9Ckay-s@DnM*R5rtu|4)~e)`z0P-sy?)kc zs_k&J@0&0!q4~%cKL)2l;N*T&0;mqX5T{Qy60%JtKTQZ-xb%KOcgqwJmb%MOOKk7N zgq})R_6**{8A|6H?fO+2`#QU)p$Ei2&nbj6TpLSIT^D$|`TcSeh+)}VMb}LmvZ{O| ze*1IdCt3+yhdYVxcM)Q_V0bIXLgr6~%JS<<&dxIgfL=Vnx4YHuU@I34JXA|+$_S3~ zy~X#gO_X!cSs^XM{yzDGNM>?v(+sF#<0;AH^YrE8smx<36bUsHbN#y57K8WEu(`qHvQ6cAZPo=J5C(lSmUCZ57Rj6cx!e^rfaI5%w}unz}4 zoX=nt)FVNV%QDJH`o!u9olLD4O5fl)xp+#RloZlaA92o3x4->?rB4`gS$;WO{R;Z3>cG3IgFX2EA?PK^M}@%1%A;?f6}s&CV$cIyEr#q5;yHdNZ9h{| z-=dX+a5elJoDo?Eq&Og!nN6A)5yYpnGEp}?=!C-V)(*~z-+?kY1Q7qs#Rsy%hu_60rdbB+QQNr?S1 z?;xtjUv|*E3}HmuNyB9aFL5H~3Ho0UsmuMZELp1a#CA1g`P{-mT?BchuLEtK}!QZ=3AWakRu~?f9V~3F;TV`5%9Pcs_$gq&CcU}r8gOO zC2&SWPsSG{&o-LIGTBqp6SLQZPvYKp$$7L4WRRZ0BR$Kf0I0SCFkqveCp@f)o8W)! z$%7D1R`&j7W9Q9CGus_)b%+B#J2G;l*FLz#s$hw{BHS~WNLODV#(!u_2Pe&tMsq={ zdm7>_WecWF#D=?eMjLj=-_z`aHMZ=3_-&E8;ibPmM}61i6J3is*=dKf%HC>=xbj4$ zS|Q-hWQ8T5mWde6h@;mS+?k=89?1FU<%qH9B(l&O>k|u_aD|DY*@~(`_pb|B#rJ&g zR0(~(68fpUPz6TdS@4JT5MOPrqDh5_H(eX1$P2SQrkvN8sTxwV>l0)Qq z0pzTuvtEAKRDkKGhhv^jk%|HQ1DdF%5oKq5BS>szk-CIke{%js?~%@$uaN3^Uz6Wf z_iyx{bZ(;9y4X&>LPV=L=d+A}7I4GkK0c1Xts{rrW1Q7apHf-))`BgC^0^F(>At1* za@e7{lq%yAkn*NH8Q1{@{lKhRg*^TfGvv!Sn*ed*x@6>M%aaqySxR|oNadYt1mpUZ z6H(rupHYf&Z z29$5g#|0MX#aR6TZ$@eGxxABRKakDYtD%5BmKp;HbG_ZbT+=81E&=XRk6m_3t9PvD zr5Cqy(v?gHcYvYvXkNH@S#Po~q(_7MOuCAB8G$a9BC##gw^5mW16cML=T=ERL7wsk zzNEayTG?mtB=x*wc@ifBCJ|irFVMOvH)AFRW8WE~U()QT=HBCe@s$dA9O!@`zAAT) zaOZ7l6vyR+Nk_OOF!ZlZmjoImKh)dxFbbR~z(cMhfeX1l7S_`;h|v3gI}n9$sSQ>+3@AFAy9=B_y$)q;Wdl|C-X|VV3w8 z2S#>|5dGA8^9%Bu&fhmVRrTX>Z7{~3V&0UpJNEl0=N32euvDGCJ>#6dUSi&PxFW*s zS`}TB>?}H(T2lxBJ!V#2taV;q%zd6fOr=SGHpoSG*4PDaiG0pdb5`jelVipkEk%FV zThLc@Hc_AL1#D&T4D=w@UezYNJ%0=f3iVRuVL5H?eeZM}4W*bomebEU@e2d`M<~uW zf#Bugwf`VezG|^Qbt6R_=U0}|=k;mIIakz99*>FrsQR{0aQRP6ko?5<7bkDN8evZ& zB@_KqQG?ErKL=1*ZM9_5?Pq%lcS4uLSzN(Mr5=t6xHLS~Ym`UgM@D&VNu8e?_=nSFtF$u@hpPSmI4Vo_t&v?>$~K4y(O~Rb*(MFy_igM7 z*~yYUyR6yQgzWnWMUgDov!!g=lInM+=lOmOk4L`O?{i&qxy&D*_qorRbDwj6?)!ef z#JLd7F6Z2I$S0iYI={rZNk*<{HtIl^mx=h>Cim*04K4+Z4IJtd*-)%6XV2(MCscPiw_a+y*?BKbTS@BZ3AUao^%Zi#PhoY9Vib4N>SE%4>=Jco0v zH_Miey{E;FkdlZSq)e<{`+S3W=*ttvD#hB8w=|2aV*D=yOV}(&p%0LbEWH$&@$X3x~CiF-?ejQ*N+-M zc8zT@3iwkdRT2t(XS`d7`tJQAjRmKAhiw{WOqpuvFp`i@Q@!KMhwKgsA}%@sw8Xo5Y=F zhRJZg)O4uqNWj?V&&vth*H#je6T}}p_<>!Dr#89q@uSjWv~JuW(>FqoJ5^ho0%K?E z9?x_Q;kmcsQ@5=}z@tdljMSt9-Z3xn$k)kEjK|qXS>EfuDmu(Z8|(W?gY6-l z@R_#M8=vxKMAoi&PwnaIYw2COJM@atcgfr=zK1bvjW?9B`-+Voe$Q+H$j!1$Tjn+* z&LY<%)L@;zhnJlB^Og6I&BOR-m?{IW;tyYC%FZ!&Z>kGjHJ6cqM-F z&19n+e1=9AH1VrVeHrIzqlC`w9=*zfmrerF?JMzO&|Mmv;!4DKc(sp+jy^Dx?(8>1 zH&yS_4yL7m&GWX~mdfgH*AB4{CKo;+egw=PrvkTaoBU+P-4u?E|&!c z)DKc;>$$B6u*Zr1SjUh2)FeuWLWHl5TH(UHWkf zLs>7px!c5n;rbe^lO@qlYLzlDVp(z?6rPZel=YB)Uv&n!2{+Mb$-vQl=xKw( zve&>xYx+jW_NJh!FV||r?;hdP*jOXYcLCp>DOtJ?2S^)DkM{{Eb zS$!L$e_o0(^}n3tA1R3-$SNvgBq;DOEo}fNc|tB%%#g4RA3{|euq)p+xd3I8^4E&m zFrD%}nvG^HUAIKe9_{tXB;tl|G<%>yk6R;8L2)KUJw4yHJXUOPM>(-+jxq4R;z8H#>rnJy*)8N+$wA$^F zN+H*3t)eFEgxLw+Nw3};4WV$qj&_D`%ADV2%r zJCPCo%{=z7;`F98(us5JnT(G@sKTZ^;2FVitXyLe-S5(hV&Ium+1pIUB(CZ#h|g)u zSLJJ<@HgrDiA-}V_6B^x1>c9B6%~847JkQ!^KLZ2skm;q*edo;UA)~?SghG8;QbHh z_6M;ouo_1rq9=x$<`Y@EA{C%6-pEV}B(1#sDoe_e1s3^Y>n#1Sw;N|}8D|s|VPd+g z-_$QhCz`vLxxrVMx3ape1xu3*wjx=yKSlM~nFgkNWb4?DDr*!?U)L_VeffF<+!j|b zZ$Wn2$TDv3C3V@BHpSgv3JUif8%hk%OsGZ=OxH@8&4`bbf$`aAMchl^qN>Eyu3JH} z9-S!x8-s4fE=lad%Pkp8hAs~u?|uRnL48O|;*DEU! zuS0{cpk%1E0nc__2%;apFsTm0bKtd&A0~S3Cj^?72-*Owk3V!ZG*PswDfS~}2<8le z5+W^`Y(&R)yVF*tU_s!XMcJS`;(Tr`J0%>p=Z&InR%D3@KEzzI+-2)HK zuoNZ&o=wUC&+*?ofPb0a(E6(<2Amd6%uSu_^-<1?hsxs~0K5^f(LsGqgEF^+0_H=uNk9S0bb!|O8d?m5gQjUKevPaO+*VfSn^2892K~%crWM8+6 z25@V?Y@J<9w%@NXh-2!}SK_(X)O4AM1-WTg>sj1{lj5@=q&dxE^9xng1_z9w9DK>| z6Iybcd0e zyi;Ew!KBRIfGPGytQ6}z}MeXCfLY0?9%RiyagSp_D1?N&c{ zyo>VbJ4Gy`@Fv+5cKgUgs~na$>BV{*em7PU3%lloy_aEovR+J7TfQKh8BJXyL6|P8un-Jnq(ghd!_HEOh$zlv2$~y3krgeH;9zC}V3f`uDtW(%mT#944DQa~^8ZI+zAUu4U(j0YcDfKR$bK#gvn_{JZ>|gZ5+)u?T$w7Q%F^;!Wk?G z(le7r!ufT*cxS}PR6hIVtXa)i`d$-_1KkyBU>qmgz-=T};uxx&sKgv48akIWQ89F{ z0XiY?WM^~;|T8zBOr zs#zuOONzH?svv*jokd5SK8wG>+yMC)LYL|vLqm^PMHcT=`}V$=nIRHe2?h)8WQa6O zPAU}d`1y(>kZiP~Gr=mtJLMu`i<2CspL|q2DqAgAD^7*$xzM`PU4^ga`ilE134XBQ z99P(LhHU@7qvl9Yzg$M`+dlS=x^(m-_3t|h>S}E0bcFMn=C|KamQ)=w2^e)35p`zY zRV8X?d;s^>Cof2SPR&nP3E+-LCkS0J$H!eh8~k0qo$}00b=7!H_I2O+Ro@3O$nPdm ztmbOO^B+IHzQ5w>@@@J4cKw5&^_w6s!s=H%&byAbUtczPQ7}wfTqxxtQNfn*u73Qw zGuWsrky_ajPx-5`R<)6xHf>C(oqGf_Fw|-U*GfS?xLML$kv;h_pZ@Kk$y0X(S+K80 z6^|z)*`5VUkawg}=z`S;VhZhxyDfrE0$(PMurAxl~<>lfZa>JZ288ULK7D` zl9|#L^JL}Y$j*j`0-K6kH#?bRmg#5L3iB4Z)%iF@SqT+Lp|{i`m%R-|ZE94Np7Pa5 zCqC^V3}B(FR340pmF*qaa}M}+h6}mqE~7Sh!9bDv9YRT|>vBNAqv09zXHMlcuhKD| zcjjA(b*XCIwJ33?CB!+;{)vX@9xns_b-VO{i0y?}{!sdXj1GM8+$#v>W7nw;+O_9B z_{4L;C6ol?(?W0<6taGEn1^uG=?Q3i29sE`RfYCaV$3DKc_;?HsL?D_fSYg}SuO5U zOB_f4^vZ_x%o`5|C@9C5+o=mFy@au{s)sKw!UgC&L35aH(sgDxRE2De%(%OT=VUdN ziVLEmdOvJ&5*tCMKRyXctCwQu_RH%;m*$YK&m;jtbdH#Ak~13T1^f89tn`A%QEHWs~jnY~E}p_Z$XC z=?YXLCkzVSK+Id`xZYTegb@W8_baLt-Fq`Tv|=)JPbFsKRm)4UW;yT+J`<)%#ue9DPOkje)YF2fsCilK9MIIK>p*`fkoD5nGfmLwt)!KOT+> zOFq*VZktDDyM3P5UOg`~XL#cbzC}eL%qMB=Q5$d89MKuN#$6|4gx_Jt0Gfn8w&q}%lq4QU%6#jT*MRT% zrLz~C8FYKHawn-EQWN1B75O&quS+Z81(zN)G>~vN8VwC+e+y(`>HcxC{MrJ;H1Z4k zZWuv$w_F0-Ub%MVcpIc){4PGL^I7M{>;hS?;eH!;gmcOE66z3;Z1Phqo(t zVP(Hg6q#0gIKgsg7L7WE!{Y#1nI(45tx2{$34dDd#!Z0NIyrm)HOn5W#7;f4pQci# zDW!FI(g4e668kI9{2+mLwB+=#9bfqgX%!B34V-$wwSN(_cm*^{y0jQtv*4}eO^sOV z*9xoNvX)c9isB}Tgx&ZRjp3kwhTVK?r9;n!x>^XYT z@Q^7zp{rkIs{2mUSE^2!Gf6$6;j~&4=-0cSJJDizZp6LTe8b45;{AKM%v99}{{FfC zz709%u0mC=1KXTo(=TqmZQ;c?$M3z(!xah>aywrj40sc2y3rKFw4jCq+Y+u=CH@_V zxz|qeTwa>+<|H%8Dz5u>ZI5MmjTFwXS-Fv!TDd*`>3{krWoNVx$<133`(ftS?ZPyY z&4@ah^3^i`vL$BZa>O|Nt?ucewzsF)0zX3qmM^|waXr=T0pfIb0*$AwU=?Ipl|1Y; z*Pk6{C-p4MY;j@IJ|DW>QHZQJcp;Z~?8(Q+Kk3^0qJ}SCk^*n4W zu9ZFwLHUx-$6xvaQ)SUQcYd6fF8&x)V`1bIuX@>{mE$b|Yd(qomn3;bPwnDUc0F=; zh*6_((%bqAYQWQ~odER?h>1mkL4kpb3s7`0m@rDKGU*oyF)$j~Ffd4fXV$?`f~rHf zB%Y)@5SXZvfwm10RY5X?TEo)PK_`L6qgBp=#>fO49$D zDq8Ozj0q6213tV5Qq=;fZ0$|KroY{Dz=l@lU^J)?Ko@ti20TRplXzphBi>XGx4bou zEWrkNjz0t5j!_ke{g5I#PUlEU$Km8g8TE|XK=MkU@PT4T><2OVamoK;wJ}3X0L$vX zgd7gNa359*nc)R-0!`2X@FOTB`+oETOPc=ubp5R)VQgY+5BTZZJ2?9QwnO=dnulIUF3gFn;BODC2)65)HeVd%t86sL7Rv^Y+nbn+&l z6BAJY(ETvwI)Ts$aiE8rht4KD*qNyE{8{x6R|%akbTBzw;2+6Echkt+W+`u^XX z_z&x%n '} +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..70890ca131cc --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/pom.xml @@ -0,0 +1,152 @@ + + 4.0.0 + org.openapitools + openapi-spring + jar + openapi-spring + 1.0.0 + + 3.0.2 + 2.1.0 + 1.7.10 + + 1.7.10 + 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.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..a4df561544f0 --- /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.22.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..4e81f86cd2bd --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/PageApi.kt @@ -0,0 +1,68 @@ +/** + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. +*/ +package org.openapitools.api + +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" + 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_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..031cefda9585 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ResponseApi.kt @@ -0,0 +1,79 @@ +/** + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.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.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], + // "/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_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..805ec72c8305 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/api/ResultApi.kt @@ -0,0 +1,68 @@ +/** + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. +*/ +package org.openapitools.api + +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> + + 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" + } +} 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..1a33c6057d4a --- /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.22.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..b2a1bf4815ef --- /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.22.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..deb41db44740 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/LogEntry.kt @@ -0,0 +1,36 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +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 + @get:JsonProperty("data") val `data`: LogEntryData? = null, + + @get:JsonProperty("severity") val severity: kotlin.String? = null, + + @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..806dacbbebc6 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/LogEntryData.kt @@ -0,0 +1,34 @@ +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 + +/** + * Payload for a log entry + * @param level + * @param message + * @param source + */ +data class LogEntryData( + + @get:JsonProperty("level") val level: kotlin.String? = null, + + @get:JsonProperty("message") val message: kotlin.String? = null, + + @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..e3c3f4ba6cb4 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/MetricsEntry.kt @@ -0,0 +1,36 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +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 + @get:JsonProperty("data") val `data`: MetricsEntryData? = null, + + @get:JsonProperty("severity") val severity: kotlin.String? = null, + + @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..5e765ee69f91 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/MetricsEntryData.kt @@ -0,0 +1,34 @@ +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 + +/** + * Payload for a metrics entry + * @param metricName + * @param `value` + * @param unit + */ +data class MetricsEntryData( + + @get:JsonProperty("metricName") val metricName: kotlin.String? = null, + + @get:JsonProperty("value") val `value`: kotlin.Double? = null, + + @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/Order.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/Order.kt new file mode 100644 index 000000000000..694e221bc01b --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/Order.kt @@ -0,0 +1,34 @@ +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 + +/** + * + * @param orderId + * @param quantity + * @param totalPrice + */ +data class Order( + + @get:JsonProperty("orderId", required = true) val orderId: kotlin.String, + + @get:JsonProperty("quantity") val quantity: kotlin.Int? = null, + + @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/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..9a6a584e626a --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/PaymentError.kt @@ -0,0 +1,34 @@ +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 + +/** + * 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, + + @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..dd68be0d7b99 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/Pet.kt @@ -0,0 +1,34 @@ +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 + +/** + * + * @param name + * @param id + * @param species + */ +data class Pet( + + @get:JsonProperty("name", required = true) val name: kotlin.String, + + @get:JsonProperty("id") val id: kotlin.Long? = null, + + @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..1d01fe3da324 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/SearchResult.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 + +/** + * Search result — unique structure, not matched by any pattern + * @param query + * @param totalHits + * @param results + * @param facets + */ +data class SearchResult( + + @get:JsonProperty("query") val query: kotlin.String? = null, + + @get:JsonProperty("totalHits") val totalHits: kotlin.Long? = null, + + @get:JsonProperty("results") val results: kotlin.collections.List? = null, + + @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..67826fd30093 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/User.kt @@ -0,0 +1,34 @@ +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 + +/** + * + * @param id + * @param name + * @param email + */ +data class User( + + @get:JsonProperty("id") val id: kotlin.String? = null, + + @get:JsonProperty("name") val name: kotlin.String? = null, + + @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..eed99960c567 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/ValidationError.kt @@ -0,0 +1,34 @@ +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 + +/** + * 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, + + @get:JsonProperty("code") val code: kotlin.String? = null +) : java.io.Serializable { + + companion object { + private const val serialVersionUID: kotlin.Long = 1 + } +} + From f279a846a9f81ee2ce345ad13fd956264f5f833c Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 24 Apr 2026 01:12:24 +0200 Subject: [PATCH 05/43] add javadoc --- .../languages/GenericSubstitutionSupport.java | 20 ++++++++++++++++ .../languages/SpringPageableSupport.java | 24 +++++++++++++++++++ 2 files changed, 44 insertions(+) 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 index ecf6bc71109f..22dd09592c02 100644 --- 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 @@ -57,6 +57,26 @@ * {@link org.openapitools.codegen.CodegenConfig#prepareSupportingFile} and * delegate to this method. * + * + *

Relationship to {@link SpringPageableSupport}

+ *

This class and {@link SpringPageableSupport} both perform return-type substitution + * for generic wrapper schemas, but they are complementary, not redundant:

+ *
    + *
  • This class ({@code genericPatterns} config) 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. It suppresses the matched wrapper schema but not any companion + * metadata schemas.
  • + *
  • {@link SpringPageableSupport} ({@code substituteGenericPagedModel} flag) uses + * structural detection: it identifies paged-model schemas by shape, requires no + * naming convention, and additionally suppresses the companion {@code PageMetadata}-style + * schema. It is specialised for the Spring {@code PagedModel} use case.
  • + *
+ * + *

When both features are active on the same spec, {@link SpringPageableSupport} runs first + * inside {@code fromOperation}. If it replaces a return type, this class will not find the + * original schema name in its registry (because {@code returnBaseType} has already changed), + * so double-substitution cannot occur.

*/ public final class GenericSubstitutionSupport { 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 index 75cc9ab778ac..3586da6d3562 100644 --- 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 @@ -46,6 +46,30 @@ *

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}

+ *

This class and {@link GenericSubstitutionSupport} both perform return-type substitution, + * but they are complementary, not redundant:

+ *
    + *
  • This class ({@code substituteGenericPagedModel} flag) uses + * structural detection via {@link PagedModelScanUtils}: it finds paged-model + * schemas by shape (a {@code content} array property + a pagination-metadata {@code $ref} + * property) regardless of naming conventions. It also suppresses the companion + * {@code PageMetadata}-style schema when it is no longer referenced. No naming convention + * or pattern config is required — just one boolean flag.
  • + *
  • {@link GenericSubstitutionSupport} ({@code genericPatterns} config) uses + * name-based pattern matching (suffix / prefix / vendor extensions). It can + * target any generic class and any number of type parameters, but relies on schemas + * following a naming convention. It does not suppress companion metadata schemas.
  • + *
+ * + *

Non-overlap guarantee

+ *

When both features are active, they process the same operation sequentially: + * {@code pageableSupport.substituteReturnType()} runs first (in {@code fromOperation}), + * then {@code genericSubstitutionSupport.substituteReturnType()} runs. If the first has + * already replaced {@code returnBaseType} with e.g. {@code "PagedModel"}, the second lookup + * into the {@code genericPatterns} registry will find no entry for that new name and will + * skip — so double-substitution cannot occur.

*/ public final class SpringPageableSupport { From cea9861b8b2391715697954623febb15e6363f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1chym=20Metli=C4=8Dka?= Date: Tue, 5 May 2026 16:52:35 +0200 Subject: [PATCH 06/43] Enhance pageable validation by adding minSize and minPage constraints. Search also allOf references for constraints and defaults --- .../languages/KotlinSpringServerCodegen.java | 2 + .../codegen/languages/SpringCodegen.java | 2 + .../languages/SpringPageableScanUtils.java | 127 +++++++++++++++--- .../JavaSpring/validPageable.mustache | 36 ++++- .../kotlin-spring/validPageable.mustache | 32 ++++- .../java/spring/SpringCodegenTest.java | 59 ++++++++ .../spring/KotlinSpringServerCodegenTest.java | 60 ++++++++- .../3_0/spring/petstore-sort-validation.yaml | 102 ++++++++++++++ 8 files changed, 391 insertions(+), 29 deletions(-) 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 6d2a86061991..8f41b31c7c69 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 @@ -1100,6 +1100,8 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation 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"); } 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 76e20d09be36..7eb9dc53f82c 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 @@ -1274,6 +1274,8 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation 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"); } 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..b91c6b0e435b 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 @@ -24,6 +24,7 @@ import io.swagger.v3.oas.models.parameters.Parameter; import org.openapitools.codegen.utils.ModelUtils; +import java.math.BigDecimal; import java.util.*; import java.util.stream.Collectors; @@ -71,22 +72,28 @@ 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; } } @@ -205,13 +212,10 @@ public static Map scanPageableDefaults( if (schema == null) { continue; } - if (schema.get$ref() != null) { - schema = ModelUtils.getReferencedSchema(openAPI, schema); - } - if (schema == null || schema.getDefault() == null) { + Object defaultValue = resolveDefault(openAPI, schema); + if (defaultValue == null) { continue; } - Object defaultValue = schema.getDefault(); switch (param.getName()) { case "page": if (defaultValue instanceof Number) { @@ -256,11 +260,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) { @@ -279,30 +284,29 @@ public static Map scanPageableConstraints( } 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(); + BigDecimal maximum = resolveMaximum(openAPI, schema); + BigDecimal minimum = resolveMinimum(openAPI, schema); switch (param.getName()) { case "page": - maxPage = maximum; + if (maximum != null) maxPage = maximum.intValue(); + if (minimum != null) minPage = minimum.intValue(); break; case "size": - maxSize = maximum; + if (maximum != null) maxSize = maximum.intValue(); + if (minimum != null) minSize = minimum.intValue(); 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 +314,83 @@ public static Map scanPageableConstraints( } return result; } + + // ------------------------------------------------------------------------- + // Private schema-resolution helpers + // ------------------------------------------------------------------------- + + /** + * Returns the effective {@code maximum} for the given schema, resolving through a top-level + * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive + * (smallest) value wins. + */ + private static BigDecimal resolveMaximum(OpenAPI openAPI, Schema schema) { + if (schema == null) return null; + if (schema.get$ref() != null) { + schema = ModelUtils.getReferencedSchema(openAPI, schema); + if (schema == null) return null; + } + BigDecimal result = schema.getMaximum(); + if (schema.getAllOf() != null) { + for (Schema allOfItem : schema.getAllOf()) { + Schema resolved = ModelUtils.getReferencedSchema(openAPI, allOfItem); + if (resolved != null && resolved.getMaximum() != null) { + if (result == null || resolved.getMaximum().compareTo(result) < 0) { + result = resolved.getMaximum(); + } + } + } + } + return result; + } + + /** + * Returns the effective {@code minimum} for the given schema, resolving through a top-level + * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive + * (largest) value wins. + */ + private static BigDecimal resolveMinimum(OpenAPI openAPI, Schema schema) { + if (schema == null) return null; + if (schema.get$ref() != null) { + schema = ModelUtils.getReferencedSchema(openAPI, schema); + if (schema == null) return null; + } + BigDecimal result = schema.getMinimum(); + if (schema.getAllOf() != null) { + for (Schema allOfItem : schema.getAllOf()) { + Schema resolved = ModelUtils.getReferencedSchema(openAPI, allOfItem); + if (resolved != null && resolved.getMinimum() != null) { + if (result == null || resolved.getMinimum().compareTo(result) > 0) { + result = resolved.getMinimum(); + } + } + } + } + return result; + } + + /** + * Returns the effective {@code default} for the given schema. Unlike constraints, the inline + * schema's default takes precedence (explicit per-endpoint override); falls back to the first + * non-null default found in {@code allOf} items. + */ + private static Object resolveDefault(OpenAPI openAPI, Schema schema) { + if (schema == null) return null; + if (schema.get$ref() != null) { + schema = ModelUtils.getReferencedSchema(openAPI, schema); + if (schema == null) return null; + } + if (schema.getDefault() != null) return schema.getDefault(); + if (schema.getAllOf() != null) { + for (Schema allOfItem : schema.getAllOf()) { + Schema resolved = ModelUtils.getReferencedSchema(openAPI, allOfItem); + if (resolved != null && resolved.getDefault() != null) { + return resolved.getDefault(); + } + } + } + return null; + } } 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/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 b5d0467b75fa..1f74191e9bef 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 @@ -7147,6 +7147,65 @@ 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")); + } + + @Test + public void scanPageableDefaultsResolvesDefaultFromAllOfRef() 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"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-sort-validation.yaml", SPRING_BOOT, props); + + // findPetsWithDefaultFromAllOfRef: default: 7 is on the referenced schema only + JavaFileAssert.assertThat(files.get("PetApi.java")) + .assertMethod("findPetsWithDefaultFromAllOfRef") + .assertParameter("pageable") + .assertParameterAnnotations() + .containsWithNameAndAttributes("PageableDefault", Map.of("size", "7")); + } + // ------------------------------------------------------------------------- // @PageableDefault / @SortDefault tests // ------------------------------------------------------------------------- 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 95c8024deccc..0d3795b75cd5 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 @@ -4231,6 +4231,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,15 +4269,67 @@ 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 generateSortValidationAddsAnnotationForExplicitPaginated() throws Exception { + 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_SORT_VALIDATION, "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"); + } + + @Test + public void scanPageableDefaultsResolvesDefaultFromAllOfRef() throws Exception { + Map additionalProperties = new HashMap<>(); + additionalProperties.put(USE_TAGS, "true"); + additionalProperties.put(INTERFACE_ONLY, "true"); + additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); + + Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); + + File petApi = files.get("PetApi.kt"); + // findPetsWithDefaultFromAllOfRef: default: 7 is on the referenced schema only + assertFileContains(petApi.toPath(), "@PageableDefault(size = 7)"); + } + + // ========== AUTO X-SPRING-PAGINATED TESTS ========== + + @Test + public void generateSortValidationAddsAnnotationForExplicitPaginated() throws Exception { + Map additionalProperties = new HashMap<>(); Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); 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 86d398d2c407..e6368583e71a 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' components: schemas: PetSort: @@ -549,6 +639,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: From 2b24608837c88baf40e132fe0ed1440b598f6d62 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 5 May 2026 23:41:50 +0200 Subject: [PATCH 07/43] update samples --- .../kotlin/org/openapitools/api/PetApi.kt | 36 +++++ .../configuration/ValidPageable.kt | 32 +++- .../java/org/openapitools/api/PetApi.java | 48 ++++++ .../configuration/ValidPageable.java | 36 ++++- .../src/main/resources/openapi.yaml | 141 ++++++++++++++++++ 5 files changed, 289 insertions(+), 4 deletions(-) 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..98e5aa03d13e 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(@PageableDefault(size = 7) 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-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..57841e79adac 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( + @PageableDefault(size = 7) 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/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 a656feda6c06..6b4efe9f886b 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 components: parameters: PetSortParam: @@ -790,6 +919,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: name: name From 1e4013ae8c8430550564119e2233e620360c7b5f Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 5 May 2026 23:49:32 +0200 Subject: [PATCH 08/43] fix test --- .../codegen/kotlin/spring/KotlinSpringServerCodegenTest.java | 4 ++++ 1 file changed, 4 insertions(+) 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 0d3795b75cd5..4606fef643cb 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 @@ -4330,6 +4330,10 @@ public void scanPageableDefaultsResolvesDefaultFromAllOfRef() throws Exception { @Test public void generateSortValidationAddsAnnotationForExplicitPaginated() 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_SORT_VALIDATION, "true"); Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); From 0701d5b65a80014578db406146cb9edbfbf11522 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 6 May 2026 00:07:56 +0200 Subject: [PATCH 09/43] use ModelUtils in SpringPageableScanUtils --- .../openapitools/codegen/languages/SpringPageableScanUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b91c6b0e435b..74ae7ae65669 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 @@ -162,7 +162,7 @@ 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) { enumSchema = ModelUtils.getReferencedSchema(openAPI, enumSchema); From b463d88e254fac279e8131d8dedee41cf430cf64 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 6 May 2026 09:42:17 +0200 Subject: [PATCH 10/43] update tests in samples --- .../openapitools/api/PetApiController.java | 21 ++++++ .../api/PetApiValidationTest.java | 66 +++++++++++++++++++ 2 files changed, 87 insertions(+) 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..12b29649db49 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 @@ -90,6 +90,16 @@ public ResponseEntity> findPetsWithPageAndSizeConstraint(Pageable page return ResponseEntity.ok(Collections.emptyList()); } + @Override + public ResponseEntity> findPetsWithMinSizeConstraintFromAllOfRef(Pageable pageable) { + return ResponseEntity.ok(Collections.emptyList()); + } + + @Override + public ResponseEntity> findPetsWithSizeConstraintFromAllOfRef(Pageable pageable) { + return ResponseEntity.ok(Collections.emptyList()); + } + // ── @PageableDefault ───────────────────────────────────────────────────── // @PageableDefault(page = 0, size = 25) @@ -106,6 +116,17 @@ public ResponseEntity> findPetsWithPageSizeDefaultsOnly(Pageable pagea return ResponseEntity.ok(Collections.emptyList()); } + // @PageableDefault(size = 7) + + @Override + public ResponseEntity> findPetsWithDefaultFromAllOfRef(Pageable pageable) { + if (pageable.getPageSize() != 7) { + throw new IllegalStateException( + "@PageableDefault size: expected 7, got " + pageable.getPageSize()); + } + return ResponseEntity.ok(Collections.emptyList()); + } + // ── @SortDefault ───────────────────────────────────────────────────────── // @SortDefault(sort = {"name"}, direction = DESC) 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..4cb3fda52b0d 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 @@ -178,4 +178,70 @@ void pageableDefaultAndSortDefaults_absentParamsResolveAllDefaults() throws Exce mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_ALL_DEFAULTS)) .andExpect(status().isOk()); } + + // ── @PageableDefault — size default from allOf $ref ─────────────────────── + // Endpoint: GET /pet/findWithDefaultFromAllOfRef @PageableDefault(size = 7) + // PetApiController asserts size == 7; returns 200 on success, throws on mismatch. + + @Test + void pageableDefault_absentSizeParamResolvesToSizeSevenDefault() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF)) + .andExpect(status().isOk()); + } + + // ── @ValidPageable — minSize constraint from allOf $ref ─────────────────── + // Endpoint: GET /pet/findWithMinSizeConstraintFromAllOfRef minSize = 5 + + @Test + void validPageable_sizeAboveMinimumReturns200() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "6")) + .andExpect(status().isOk()); + } + + @Test + void validPageable_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_sizeBelowMinimumReturns400() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "4")) + .andExpect(status().isBadRequest()); + } + + @Test + void validPageable_unpagedPageableIsAllowedForMinConstraint() throws Exception { + // Unpaged Pageable (no params, no @PageableDefault) bypasses the validator per + // PageableConstraintValidator#isValid which returns true immediately for !isPaged(). + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF)) + .andExpect(status().isOk()); + } + + // ── @ValidPageable — maxSize constraint from allOf $ref ─────────────────── + // Endpoint: GET /pet/findWithSizeConstraintFromAllOfRef maxSize = 75 + + @Test + void validPageable_sizeBelowMaximumReturns200_forSizeConstraintFromAllOfRef() 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_sizeAtMaximumReturns200_forSizeConstraintFromAllOfRef() 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_sizeExceedsMaximumReturns400_forSizeConstraintFromAllOfRef() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "76")) + .andExpect(status().isBadRequest()); + } } From cec4212c66a791c112df00e6f046273a02b7fb71 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 6 May 2026 12:42:46 +0200 Subject: [PATCH 11/43] move reusable code to shared ModelUtils.java --- .../languages/SpringPageableScanUtils.java | 84 +----------------- .../codegen/utils/ModelUtils.java | 88 +++++++++++++++++++ 2 files changed, 91 insertions(+), 81 deletions(-) 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 74ae7ae65669..7e4c09009e1b 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 @@ -212,7 +212,7 @@ public static Map scanPageableDefaults( if (schema == null) { continue; } - Object defaultValue = resolveDefault(openAPI, schema); + Object defaultValue = ModelUtils.resolveDefault(openAPI, schema); if (defaultValue == null) { continue; } @@ -291,8 +291,8 @@ public static Map scanPageableConstraints( if (schema == null) { continue; } - BigDecimal maximum = resolveMaximum(openAPI, schema); - BigDecimal minimum = resolveMinimum(openAPI, schema); + BigDecimal maximum = ModelUtils.resolveMaximum(openAPI, schema); + BigDecimal minimum = ModelUtils.resolveMinimum(openAPI, schema); switch (param.getName()) { case "page": if (maximum != null) maxPage = maximum.intValue(); @@ -315,82 +315,4 @@ public static Map scanPageableConstraints( return result; } - // ------------------------------------------------------------------------- - // Private schema-resolution helpers - // ------------------------------------------------------------------------- - - /** - * Returns the effective {@code maximum} for the given schema, resolving through a top-level - * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). - * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive - * (smallest) value wins. - */ - private static BigDecimal resolveMaximum(OpenAPI openAPI, Schema schema) { - if (schema == null) return null; - if (schema.get$ref() != null) { - schema = ModelUtils.getReferencedSchema(openAPI, schema); - if (schema == null) return null; - } - BigDecimal result = schema.getMaximum(); - if (schema.getAllOf() != null) { - for (Schema allOfItem : schema.getAllOf()) { - Schema resolved = ModelUtils.getReferencedSchema(openAPI, allOfItem); - if (resolved != null && resolved.getMaximum() != null) { - if (result == null || resolved.getMaximum().compareTo(result) < 0) { - result = resolved.getMaximum(); - } - } - } - } - return result; - } - - /** - * Returns the effective {@code minimum} for the given schema, resolving through a top-level - * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). - * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive - * (largest) value wins. - */ - private static BigDecimal resolveMinimum(OpenAPI openAPI, Schema schema) { - if (schema == null) return null; - if (schema.get$ref() != null) { - schema = ModelUtils.getReferencedSchema(openAPI, schema); - if (schema == null) return null; - } - BigDecimal result = schema.getMinimum(); - if (schema.getAllOf() != null) { - for (Schema allOfItem : schema.getAllOf()) { - Schema resolved = ModelUtils.getReferencedSchema(openAPI, allOfItem); - if (resolved != null && resolved.getMinimum() != null) { - if (result == null || resolved.getMinimum().compareTo(result) > 0) { - result = resolved.getMinimum(); - } - } - } - } - return result; - } - - /** - * Returns the effective {@code default} for the given schema. Unlike constraints, the inline - * schema's default takes precedence (explicit per-endpoint override); falls back to the first - * non-null default found in {@code allOf} items. - */ - private static Object resolveDefault(OpenAPI openAPI, Schema schema) { - if (schema == null) return null; - if (schema.get$ref() != null) { - schema = ModelUtils.getReferencedSchema(openAPI, schema); - if (schema == null) return null; - } - if (schema.getDefault() != null) return schema.getDefault(); - if (schema.getAllOf() != null) { - for (Schema allOfItem : schema.getAllOf()) { - Schema resolved = ModelUtils.getReferencedSchema(openAPI, allOfItem); - if (resolved != null && resolved.getDefault() != null) { - return resolved.getDefault(); - } - } - } - return null; - } } 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..763769927c26 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 @@ -852,6 +852,94 @@ public static boolean isModelWithPropertiesOnly(Schema schema) { ); } + /** + * Returns the effective {@code maximum} for the given schema, resolving through a top-level + * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive + * (smallest) value wins. + * + * @param openAPI the OpenAPI document used to resolve {@code $ref}s + * @param schema the schema to inspect + * @return the effective maximum, or {@code null} if none is defined + */ + public static BigDecimal resolveMaximum(OpenAPI openAPI, Schema schema) { + if (schema == null) return null; + if (schema.get$ref() != null) { + schema = getReferencedSchema(openAPI, schema); + if (schema == null) return null; + } + BigDecimal result = schema.getMaximum(); + if (schema.getAllOf() != null) { + for (Schema allOfItem : schema.getAllOf()) { + Schema resolved = getReferencedSchema(openAPI, allOfItem); + if (resolved != null && resolved.getMaximum() != null) { + if (result == null || resolved.getMaximum().compareTo(result) < 0) { + result = resolved.getMaximum(); + } + } + } + } + return result; + } + + /** + * Returns the effective {@code minimum} for the given schema, resolving through a top-level + * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive + * (largest) value wins. + * + * @param openAPI the OpenAPI document used to resolve {@code $ref}s + * @param schema the schema to inspect + * @return the effective minimum, or {@code null} if none is defined + */ + public static BigDecimal resolveMinimum(OpenAPI openAPI, Schema schema) { + if (schema == null) return null; + if (schema.get$ref() != null) { + schema = getReferencedSchema(openAPI, schema); + if (schema == null) return null; + } + BigDecimal result = schema.getMinimum(); + if (schema.getAllOf() != null) { + for (Schema allOfItem : schema.getAllOf()) { + Schema resolved = getReferencedSchema(openAPI, allOfItem); + if (resolved != null && resolved.getMinimum() != null) { + if (result == null || resolved.getMinimum().compareTo(result) > 0) { + result = resolved.getMinimum(); + } + } + } + } + return result; + } + + /** + * Returns the effective {@code default} for the given schema, resolving through a top-level + * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * Unlike constraints, the inline schema's default takes precedence (explicit per-endpoint + * override); falls back to the first non-null default found in {@code allOf} items. + * + * @param openAPI the OpenAPI document used to resolve {@code $ref}s + * @param schema the schema to inspect + * @return the effective default value, or {@code null} if none is defined + */ + public static Object resolveDefault(OpenAPI openAPI, Schema schema) { + if (schema == null) return null; + if (schema.get$ref() != null) { + schema = getReferencedSchema(openAPI, schema); + if (schema == null) return null; + } + if (schema.getDefault() != null) return schema.getDefault(); + if (schema.getAllOf() != null) { + for (Schema allOfItem : schema.getAllOf()) { + Schema resolved = getReferencedSchema(openAPI, allOfItem); + if (resolved != null && resolved.getDefault() != null) { + return resolved.getDefault(); + } + } + } + return null; + } + public static boolean hasValidation(Schema sc) { return ( sc.getMaxItems() != null || From 7151bb390e6defdcc4680c9b33e9121bec385f7f Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 6 May 2026 12:46:10 +0200 Subject: [PATCH 12/43] add tests --- .../codegen/utils/ModelUtilsTest.java | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) 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..a445a02391b8 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,234 @@ public void getParentNameMultipleInterfacesTest() { Schema composedSchema = allSchemas.get("RandomAnimalsResponse_animals_inner"); assertNull(ModelUtils.getParentName(composedSchema, allSchemas)); } + + // ------------------------------------------------------------------------- + // resolveMaximum + // ------------------------------------------------------------------------- + + @Test + public void resolveMaximum_nullSchema_returnsNull() { + assertNull(ModelUtils.resolveMaximum(new OpenAPI(), null)); + } + + @Test + public void resolveMaximum_noMaximumDefined_returnsNull() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + assertNull(ModelUtils.resolveMaximum(openAPI, schema)); + } + + @Test + public void resolveMaximum_inlineMaximum_returnsIt() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMaximum(BigDecimal.valueOf(100)); + assertEquals(ModelUtils.resolveMaximum(openAPI, schema), BigDecimal.valueOf(100)); + } + + @Test + public void resolveMaximum_refToSchemaWithMaximum_resolvesRef() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema refTarget = new IntegerSchema(); + refTarget.setMaximum(BigDecimal.valueOf(50)); + openAPI.getComponents().addSchemas("MyInt", refTarget); + + Schema ref = new Schema<>().$ref("#/components/schemas/MyInt"); + assertEquals(ModelUtils.resolveMaximum(openAPI, ref), BigDecimal.valueOf(50)); + } + + @Test + public void resolveMaximum_allOf_returnsMostRestrictive() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // allOf item with max=200 and item with max=50 — 50 should win + 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") + )); + assertEquals(ModelUtils.resolveMaximum(openAPI, schema), BigDecimal.valueOf(50)); + } + + @Test + public void resolveMaximum_inlineAndAllOf_mostRestrictiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // allOf item has maximum=30, which is more restrictive than inline maximum=100 + 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"))); + assertEquals(ModelUtils.resolveMaximum(openAPI, schema), BigDecimal.valueOf(30)); + } + + @Test + public void resolveMaximum_allOfItemWithoutMaximum_ignored() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + openAPI.getComponents().addSchemas("NoMax", new IntegerSchema()); // no maximum + + Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/NoMax"))); + assertNull(ModelUtils.resolveMaximum(openAPI, schema)); + } + + // ------------------------------------------------------------------------- + // resolveMinimum + // ------------------------------------------------------------------------- + + @Test + public void resolveMinimum_nullSchema_returnsNull() { + assertNull(ModelUtils.resolveMinimum(new OpenAPI(), null)); + } + + @Test + public void resolveMinimum_noMinimumDefined_returnsNull() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + assertNull(ModelUtils.resolveMinimum(openAPI, new IntegerSchema())); + } + + @Test + public void resolveMinimum_inlineMinimum_returnsIt() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMinimum(BigDecimal.valueOf(1)); + assertEquals(ModelUtils.resolveMinimum(openAPI, schema), BigDecimal.valueOf(1)); + } + + @Test + public void resolveMinimum_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"); + assertEquals(ModelUtils.resolveMinimum(openAPI, ref), BigDecimal.valueOf(5)); + } + + @Test + public void resolveMinimum_allOf_returnsMostRestrictive() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // allOf item with min=1 and item with min=10 — 10 should win (larger = more restrictive lower bound) + 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") + )); + assertEquals(ModelUtils.resolveMinimum(openAPI, schema), BigDecimal.valueOf(10)); + } + + @Test + public void resolveMinimum_inlineAndAllOf_mostRestrictiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // allOf item has minimum=20, which is more restrictive than inline minimum=0 + 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"))); + assertEquals(ModelUtils.resolveMinimum(openAPI, schema), BigDecimal.valueOf(20)); + } + + @Test + public void resolveMinimum_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.resolveMinimum(openAPI, schema)); + } + + // ------------------------------------------------------------------------- + // resolveDefault + // ------------------------------------------------------------------------- + + @Test + public void resolveDefault_nullSchema_returnsNull() { + assertNull(ModelUtils.resolveDefault(new OpenAPI(), null)); + } + + @Test + public void resolveDefault_noDefaultDefined_returnsNull() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + assertNull(ModelUtils.resolveDefault(openAPI, new IntegerSchema())); + } + + @Test + public void resolveDefault_inlineDefault_returnsIt() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setDefault(10); + assertEquals(ModelUtils.resolveDefault(openAPI, schema), 10); + } + + @Test + public void resolveDefault_refToSchemaWithDefault_resolvesRef() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema refTarget = new IntegerSchema(); + refTarget.setDefault(0); + openAPI.getComponents().addSchemas("MyInt", refTarget); + + Schema ref = new Schema<>().$ref("#/components/schemas/MyInt"); + assertEquals(ModelUtils.resolveDefault(openAPI, ref), 0); + } + + @Test + public void resolveDefault_allOfItemHasDefault_returnsIt() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema allOfItem = new IntegerSchema(); + allOfItem.setDefault(20); + openAPI.getComponents().addSchemas("Base", allOfItem); + + // Inline schema has no default; allOf item has default=20 + Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + assertEquals(ModelUtils.resolveDefault(openAPI, schema), 20); + } + + @Test + public void resolveDefault_inlineDefaultTakesPrecedenceOverAllOf() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema allOfItem = new IntegerSchema(); + allOfItem.setDefault(99); + openAPI.getComponents().addSchemas("Base", allOfItem); + + // Inline schema default=5 should win over allOf item default=99 + Schema schema = new IntegerSchema(); + schema.setDefault(5); + schema.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + assertEquals(ModelUtils.resolveDefault(openAPI, schema), 5); + } + + @Test + public void resolveDefault_allOfItemsNoDefault_returnsNull() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + openAPI.getComponents().addSchemas("Base", new IntegerSchema()); // no default + + Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + assertNull(ModelUtils.resolveDefault(openAPI, schema)); + } + + @Test + public void resolveDefault_stringDefault_returnsIt() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new StringSchema(); + schema.setDefault("hello"); + assertEquals(ModelUtils.resolveDefault(openAPI, schema), "hello"); + } } From daa33e017de3ee22ba1b31d2fddf10b7017f31bb Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 6 May 2026 15:01:38 +0200 Subject: [PATCH 13/43] fix CR suggestion and add tests --- .../languages/SpringPageableScanUtils.java | 2 +- .../SpringPageableScanUtilsTest.java | 154 ++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java 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 7e4c09009e1b..88311a2b2ddd 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 @@ -164,7 +164,7 @@ public static Map> scanSortValidationEnums( Schema enumSchema = schema; if (ModelUtils.isArraySchema(schema)) { enumSchema = schema.getItems(); - if (enumSchema.get$ref() != null) { + if (enumSchema != null && enumSchema.get$ref() != null) { enumSchema = ModelUtils.getReferencedSchema(openAPI, enumSchema); } } 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..6a27ccb216a7 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java @@ -0,0 +1,154 @@ +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.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.Parameter; +import org.testng.annotations.Test; + +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. + */ + 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(); + } +} From 609fc8757033a63a1bba111c18932a28480ee291 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 6 May 2026 16:42:18 +0200 Subject: [PATCH 14/43] suppress paged schemas correctly when modelNameSuffix/Prefix is set --- .../languages/KotlinSpringServerCodegen.java | 19 ++-- .../languages/PagedModelScanUtils.java | 86 +++++++++++++++++-- .../codegen/languages/SpringCodegen.java | 19 ++-- .../java/spring/SpringCodegenTest.java | 53 ++++++++++++ .../spring/KotlinSpringServerCodegenTest.java | 56 +++++++++++- .../languages/PagedModelScanUtilsTest.java | 53 ++++++++++++ 6 files changed, 262 insertions(+), 24 deletions(-) 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 04bbdabff195..9f35a5d8012c 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 @@ -1214,7 +1214,7 @@ public void preprocessOpenAPI(OpenAPI openAPI) { } if (substituteGenericPagedModel) { - pagedModelRegistry = PagedModelScanUtils.scanPagedModels(openAPI); + pagedModelRegistry = PagedModelScanUtils.scanPagedModels(openAPI, this::toModelName); if (!pagedModelRegistry.isEmpty()) { boolean customMapping = importMapping.containsKey("PagedModel"); importMapping.putIfAbsent("PagedModel", configPackage + ".PagedModel"); @@ -1385,24 +1385,27 @@ public Map postProcessAllModels(Map objs) 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<>(); + // metaSchemasToCheck maps transformed name (for imports check) → raw name (for objs.remove) + Map metaSchemasToCheck = new LinkedHashMap<>(); for (PagedModelScanUtils.DetectedPagedModel detected : pagedModelRegistry.values()) { if (detected.metaSchemaName != null) { - metaSchemasToCheck.add(detected.metaSchemaName); + metaSchemasToCheck.put(detected.metaSchemaName, detected.rawMetaSchemaName); } } // 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) { + // objs is keyed by raw schema name (DefaultGenerator uses the raw OpenAPI name as key) + if (objs.remove(detected.rawSchemaName) != null) { LOGGER.info("substituteGenericPagedModel: suppressing model '{}' — replaced by PagedModel<{}>", - schemaName, detected.itemSchemaName); + detected.rawSchemaName, 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) { + for (Map.Entry metaEntry : metaSchemasToCheck.entrySet()) { + String metaName = metaEntry.getKey(); // transformed — matches cm.imports values + String rawMetaName = metaEntry.getValue(); // raw — matches objs key boolean referencedElsewhere = objs.values().stream() .flatMap(mm -> mm.getModels().stream()) .map(ModelMap::getModel) @@ -1410,7 +1413,7 @@ public Map postProcessAllModels(Map objs) if (referencedElsewhere) { LOGGER.info("substituteGenericPagedModel: keeping pagination metadata model '{}'" + " — referenced by a non-paged schema", metaName); - } else if (objs.remove(metaName) != null) { + } else if (objs.remove(rawMetaName) != null) { LOGGER.info("substituteGenericPagedModel: suppressing pagination metadata model '{}'" + " — replaced by PagedModel.PageMetadata", metaName); } 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..f948e4da1d3e 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java @@ -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,56 @@ 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:

+ *
    + *
  • transformed ({@code schemaName} / {@code metaSchemaName}) — the model name + * after the generator's {@code toModelName()} has been applied. These are the names + * that appear in codegen-operation imports and {@code CodegenModel.imports}, so they + * must be used for import removal / import-presence checks.
  • + *
  • raw ({@code rawSchemaName} / {@code rawMetaSchemaName}) — the original + * OpenAPI component-schema name. {@code DefaultGenerator} keys {@code allProcessedModels} + * (the {@code objs} map passed to {@code postProcessAllModels}) by the raw + * schema name, so these values must be used for {@code objs.remove()} calls.
  • + *
+ * + *

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

+ * + * @param schemaName Transformed model name of the detected paged schema. + * @param itemSchemaName Raw item schema name (always raw; callers apply + * {@code toModelName()} at the point of use). + * @param metaSchemaName Transformed model name of the pagination-metadata schema, + * or {@code null} if unresolved. + * @param rawSchemaName Raw OpenAPI schema name of the paged schema (for {@code objs.remove}). + * @param rawMetaSchemaName Raw OpenAPI schema name of the pagination-metadata schema + * (for {@code objs.remove}), or {@code null} if unresolved. */ public static final class DetectedPagedModel { + /** Transformed model name — use for import removal / import-presence checks. */ public final String schemaName; public final String itemSchemaName; + /** Transformed meta model name — use for import-presence checks. */ public final String metaSchemaName; - + /** Raw OpenAPI schema name — use for {@code objs.remove()} in {@code postProcessAllModels}. */ + public final String rawSchemaName; + /** Raw OpenAPI meta schema name — 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 +148,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 8870a8ef9359..bfb320edfd47 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 @@ -872,7 +872,7 @@ public void preprocessOpenAPI(OpenAPI openAPI) { } if (substituteGenericPagedModel) { - pagedModelRegistry = PagedModelScanUtils.scanPagedModels(openAPI); + pagedModelRegistry = PagedModelScanUtils.scanPagedModels(openAPI, this::toModelName); if (!pagedModelRegistry.isEmpty()) { boolean customMapping = importMapping.containsKey("PagedModel"); importMapping.putIfAbsent("PagedModel", configPackage + ".PagedModel"); @@ -1452,24 +1452,27 @@ public Map postProcessAllModels(Map objs) 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<>(); + // metaSchemasToCheck maps transformed name (for imports check) → raw name (for objs.remove) + Map metaSchemasToCheck = new LinkedHashMap<>(); for (PagedModelScanUtils.DetectedPagedModel detected : pagedModelRegistry.values()) { if (detected.metaSchemaName != null) { - metaSchemasToCheck.add(detected.metaSchemaName); + metaSchemasToCheck.put(detected.metaSchemaName, detected.rawMetaSchemaName); } } // 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) { + // objs is keyed by raw schema name (DefaultGenerator uses the raw OpenAPI name as key) + if (objs.remove(detected.rawSchemaName) != null) { LOGGER.info("substituteGenericPagedModel: suppressing model '{}' — replaced by PagedModel<{}>", - schemaName, detected.itemSchemaName); + detected.rawSchemaName, 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) { + for (Map.Entry metaEntry : metaSchemasToCheck.entrySet()) { + String metaName = metaEntry.getKey(); // transformed — matches cm.imports values + String rawMetaName = metaEntry.getValue(); // raw — matches objs key boolean referencedElsewhere = objs.values().stream() .flatMap(mm -> mm.getModels().stream()) .map(ModelMap::getModel) @@ -1477,7 +1480,7 @@ public Map postProcessAllModels(Map objs) if (referencedElsewhere) { LOGGER.info("substituteGenericPagedModel: keeping pagination metadata model '{}'" + " — referenced by a non-paged schema", metaName); - } else if (objs.remove(metaName) != null) { + } else if (objs.remove(rawMetaName) != null) { LOGGER.info("substituteGenericPagedModel: suppressing pagination metadata model '{}'" + " — replaced by PagedModel.PageMetadata", metaName); } 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 ae54a9ca85a2..f9d37a199c66 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 @@ -7642,6 +7642,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[][]{ 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 007a4aeb2634..0e3f0c4c5b1b 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 @@ -5958,7 +5958,61 @@ private Map springCloudKotlinPagedModelProps() { return props; } - @Test(description = "oneOf with discriminator generates thin sealed interface with Jackson annotations") + // ------------------------------------------------------------------------- + // 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"); + } + + public void testOneOfWithDiscriminatorGeneratesThinInterface() throws IOException { File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); output.deleteOnExit(); 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(); + } } + From ffdb9e32f75d19a8e28fbbd9c6996779ca8763a3 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 7 May 2026 12:56:55 +0200 Subject: [PATCH 15/43] refactor: enhance pageable constraints handling with exclusive bounds support --- .../languages/SpringPageableScanUtils.java | 23 +- .../codegen/utils/ModelUtils.java | 242 +++++++++++++---- .../SpringPageableScanUtilsTest.java | 124 +++++++++ .../codegen/utils/ModelUtilsTest.java | 254 +++++++++++++++--- 4 files changed, 544 insertions(+), 99 deletions(-) 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 88311a2b2ddd..a8a8173e47c3 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 @@ -24,7 +24,6 @@ import io.swagger.v3.oas.models.parameters.Parameter; import org.openapitools.codegen.utils.ModelUtils; -import java.math.BigDecimal; import java.util.*; import java.util.stream.Collectors; @@ -186,7 +185,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) { @@ -265,7 +264,7 @@ public static Map scanPageableDefaults( * {@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 constraint are included) + * at least one constraint are included) */ public static Map scanPageableConstraints( OpenAPI openAPI, boolean autoXSpringPaginated) { @@ -291,16 +290,22 @@ public static Map scanPageableConstraints( if (schema == null) { continue; } - BigDecimal maximum = ModelUtils.resolveMaximum(openAPI, schema); - BigDecimal minimum = ModelUtils.resolveMinimum(openAPI, schema); + ModelUtils.ResolvedMaxBound maxBound = ModelUtils.resolveMaximumBound(openAPI, schema); + ModelUtils.ResolvedMinBound minBound = ModelUtils.resolveMinimumBound(openAPI, schema); + Integer adjustedMaxBound = maxBound == null + ? null + : (maxBound.exclusive ? maxBound.maxBound.intValue() - 1 : maxBound.maxBound.intValue()); + Integer adjustedMinBound = minBound == null + ? null + : (minBound.exclusive ? minBound.minBound.intValue() + 1 : minBound.minBound.intValue()); switch (param.getName()) { case "page": - if (maximum != null) maxPage = maximum.intValue(); - if (minimum != null) minPage = minimum.intValue(); + if (adjustedMaxBound != null) maxPage = adjustedMaxBound; + if (adjustedMinBound != null) minPage = adjustedMinBound; break; case "size": - if (maximum != null) maxSize = maximum.intValue(); - if (minimum != null) minSize = minimum.intValue(); + if (adjustedMaxBound != null) maxSize = adjustedMaxBound; + if (adjustedMinBound != null) minSize = adjustedMinBound; break; default: break; 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 763769927c26..89fc6e43ebf6 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; @@ -852,64 +854,182 @@ public static boolean isModelWithPropertiesOnly(Schema schema) { ); } + 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; + } + } + /** - * Returns the effective {@code maximum} for the given schema, resolving through a top-level - * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * 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. + * (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, or {@code null} if none is defined + * @return the effective maximum bound, or {@code null} if none is defined */ - public static BigDecimal resolveMaximum(OpenAPI openAPI, Schema schema) { + @Nullable + public static ResolvedMaxBound resolveMaximumBound(OpenAPI openAPI, Schema schema) { if (schema == null) return null; + if (schema.get$ref() != null) { schema = getReferencedSchema(openAPI, schema); - if (schema == null) return null; } - BigDecimal result = schema.getMaximum(); - if (schema.getAllOf() != null) { - for (Schema allOfItem : schema.getAllOf()) { - Schema resolved = getReferencedSchema(openAPI, allOfItem); - if (resolved != null && resolved.getMaximum() != null) { - if (result == null || resolved.getMaximum().compareTo(result) < 0) { - result = resolved.getMaximum(); - } - } - } - } - return result; + if (schema == null) return null; + + ResolvedMaxBound result = extractMaxBound(schema); + List allOf = schema.getAllOf(); + if (allOf == null) return result; + + return allOf.stream() + .map(allOfItem -> getReferencedSchema(openAPI, allOfItem)) + .filter(Objects::nonNull) + .map(ModelUtils::extractMaxBound) + .reduce(result, ResolvedMaxBound::getSmallerMaxBound); } /** - * Returns the effective {@code minimum} for the given schema, resolving through a top-level - * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * 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. + * (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, or {@code null} if none is defined + * @return the effective minimum bound, or {@code null} if none is defined */ - public static BigDecimal resolveMinimum(OpenAPI openAPI, Schema schema) { + @Nullable + public static ResolvedMinBound resolveMinimumBound(OpenAPI openAPI, Schema schema) { if (schema == null) return null; + if (schema.get$ref() != null) { schema = getReferencedSchema(openAPI, schema); - if (schema == null) return null; - } - BigDecimal result = schema.getMinimum(); - if (schema.getAllOf() != null) { - for (Schema allOfItem : schema.getAllOf()) { - Schema resolved = getReferencedSchema(openAPI, allOfItem); - if (resolved != null && resolved.getMinimum() != null) { - if (result == null || resolved.getMinimum().compareTo(result) > 0) { - result = resolved.getMinimum(); - } - } - } } - return result; + if (schema == null) return null; + + ResolvedMinBound result = extractMinBound(schema); + List allOf = schema.getAllOf(); + if (allOf == null) return result; + + return allOf.stream() + .map(allOfItem -> getReferencedSchema(openAPI, allOfItem)) + .filter(Objects::nonNull) + .map(ModelUtils::extractMinBound) + .reduce(result, ResolvedMinBound::getLargerMinBound); } /** @@ -923,21 +1043,32 @@ public static BigDecimal resolveMinimum(OpenAPI openAPI, Schema schema) { * @return the effective default value, or {@code null} if none is defined */ public static Object resolveDefault(OpenAPI openAPI, Schema schema) { - if (schema == null) return null; - if (schema.get$ref() != null) { - schema = getReferencedSchema(openAPI, schema); - if (schema == null) return null; + if (schema == null) { + return null; } - if (schema.getDefault() != null) return schema.getDefault(); - if (schema.getAllOf() != null) { - for (Schema allOfItem : schema.getAllOf()) { - Schema resolved = getReferencedSchema(openAPI, allOfItem); - if (resolved != null && resolved.getDefault() != null) { - return resolved.getDefault(); - } - } + + Schema resolvedSchema = getReferencedSchema(openAPI, schema); + if (resolvedSchema == null) { + return null; } - return null; + + Object defaultValue = resolvedSchema.getDefault(); + if (defaultValue != null) { + return defaultValue; + } + + List allOf = resolvedSchema.getAllOf(); + if (allOf == null) { + return null; + } + + return allOf.stream() + .map(item -> getReferencedSchema(openAPI, item)) + .filter(Objects::nonNull) + .map(Schema::getDefault) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); } public static boolean hasValidation(Schema sc) { @@ -2406,8 +2537,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 */ @@ -2551,8 +2682,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) { @@ -2632,8 +2763,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/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java index 6a27ccb216a7..a2e7136a8259 100644 --- 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 @@ -5,11 +5,13 @@ 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.testng.annotations.Test; +import java.math.BigDecimal; import java.util.List; import java.util.Map; @@ -25,6 +27,128 @@ 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. */ 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 a445a02391b8..0acb4d4a7e94 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 @@ -759,44 +759,110 @@ public void getParentNameMultipleInterfacesTest() { } // ------------------------------------------------------------------------- - // resolveMaximum + // resolveMaximumBound // ------------------------------------------------------------------------- @Test - public void resolveMaximum_nullSchema_returnsNull() { - assertNull(ModelUtils.resolveMaximum(new OpenAPI(), null)); + public void resolveMaximumBound_nullSchema_returnsNull() { + assertNull(ModelUtils.resolveMaximumBound(new OpenAPI(), null)); } @Test - public void resolveMaximum_noMaximumDefined_returnsNull() { + public void resolveMaximumBound_noMaximumDefined_returnsNull() { OpenAPI openAPI = TestUtils.createOpenAPI(); Schema schema = new IntegerSchema(); - assertNull(ModelUtils.resolveMaximum(openAPI, schema)); + assertNull(ModelUtils.resolveMaximumBound(openAPI, schema)); } @Test - public void resolveMaximum_inlineMaximum_returnsIt() { + public void resolveMaximumBound_inclusiveMaximum_returnsInclusiveBound() { OpenAPI openAPI = TestUtils.createOpenAPI(); Schema schema = new IntegerSchema(); schema.setMaximum(BigDecimal.valueOf(100)); - assertEquals(ModelUtils.resolveMaximum(openAPI, schema), BigDecimal.valueOf(100)); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(100), bound.maxBound); + assertFalse(bound.exclusive); } @Test - public void resolveMaximum_refToSchemaWithMaximum_resolvesRef() { + 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"); - assertEquals(ModelUtils.resolveMaximum(openAPI, ref), BigDecimal.valueOf(50)); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, ref); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(50), bound.maxBound); + assertTrue(bound.exclusive); } @Test - public void resolveMaximum_allOf_returnsMostRestrictive() { + public void resolveMaximumBound_allOf_returnsMostRestrictive() { OpenAPI openAPI = TestUtils.createOpenAPI(); - // allOf item with max=200 and item with max=50 — 50 should win Schema loose = new IntegerSchema(); loose.setMaximum(BigDecimal.valueOf(200)); openAPI.getComponents().addSchemas("Loose", loose); @@ -809,13 +875,38 @@ public void resolveMaximum_allOf_returnsMostRestrictive() { new Schema<>().$ref("#/components/schemas/Loose"), new Schema<>().$ref("#/components/schemas/Strict") )); - assertEquals(ModelUtils.resolveMaximum(openAPI, schema), BigDecimal.valueOf(50)); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(50), bound.maxBound); + assertFalse(bound.exclusive); } @Test - public void resolveMaximum_inlineAndAllOf_mostRestrictiveWins() { + 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(); - // allOf item has maximum=30, which is more restrictive than inline maximum=100 Schema allOfItem = new IntegerSchema(); allOfItem.setMaximum(BigDecimal.valueOf(30)); openAPI.getComponents().addSchemas("Base", allOfItem); @@ -823,56 +914,122 @@ public void resolveMaximum_inlineAndAllOf_mostRestrictiveWins() { Schema schema = new IntegerSchema(); schema.setMaximum(BigDecimal.valueOf(100)); schema.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); - assertEquals(ModelUtils.resolveMaximum(openAPI, schema), BigDecimal.valueOf(30)); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(30), bound.maxBound); } @Test - public void resolveMaximum_allOfItemWithoutMaximum_ignored() { + public void resolveMaximumBound_allOfItemWithoutMaximum_ignored() { OpenAPI openAPI = TestUtils.createOpenAPI(); - openAPI.getComponents().addSchemas("NoMax", new IntegerSchema()); // no maximum + openAPI.getComponents().addSchemas("NoMax", new IntegerSchema()); Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/NoMax"))); - assertNull(ModelUtils.resolveMaximum(openAPI, schema)); + assertNull(ModelUtils.resolveMaximumBound(openAPI, schema)); } // ------------------------------------------------------------------------- - // resolveMinimum + // resolveMinimumBound // ------------------------------------------------------------------------- @Test - public void resolveMinimum_nullSchema_returnsNull() { - assertNull(ModelUtils.resolveMinimum(new OpenAPI(), null)); + public void resolveMinimumBound_nullSchema_returnsNull() { + assertNull(ModelUtils.resolveMinimumBound(new OpenAPI(), null)); } @Test - public void resolveMinimum_noMinimumDefined_returnsNull() { + public void resolveMinimumBound_noMinimumDefined_returnsNull() { OpenAPI openAPI = TestUtils.createOpenAPI(); - assertNull(ModelUtils.resolveMinimum(openAPI, new IntegerSchema())); + assertNull(ModelUtils.resolveMinimumBound(openAPI, new IntegerSchema())); } @Test - public void resolveMinimum_inlineMinimum_returnsIt() { + public void resolveMinimumBound_inclusiveMinimum_returnsInclusiveBound() { OpenAPI openAPI = TestUtils.createOpenAPI(); Schema schema = new IntegerSchema(); schema.setMinimum(BigDecimal.valueOf(1)); - assertEquals(ModelUtils.resolveMinimum(openAPI, schema), BigDecimal.valueOf(1)); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(1), bound.minBound); + assertFalse(bound.exclusive); } @Test - public void resolveMinimum_refToSchemaWithMinimum_resolvesRef() { + 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"); - assertEquals(ModelUtils.resolveMinimum(openAPI, ref), BigDecimal.valueOf(5)); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, ref); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(5), bound.minBound); + assertFalse(bound.exclusive); } @Test - public void resolveMinimum_allOf_returnsMostRestrictive() { + public void resolveMinimumBound_allOf_returnsMostRestrictive() { OpenAPI openAPI = TestUtils.createOpenAPI(); - // allOf item with min=1 and item with min=10 — 10 should win (larger = more restrictive lower bound) Schema permissive = new IntegerSchema(); permissive.setMinimum(BigDecimal.valueOf(1)); openAPI.getComponents().addSchemas("Permissive", permissive); @@ -885,13 +1042,38 @@ public void resolveMinimum_allOf_returnsMostRestrictive() { new Schema<>().$ref("#/components/schemas/Permissive"), new Schema<>().$ref("#/components/schemas/Strict") )); - assertEquals(ModelUtils.resolveMinimum(openAPI, schema), BigDecimal.valueOf(10)); + 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 resolveMinimum_inlineAndAllOf_mostRestrictiveWins() { + public void resolveMinimumBound_inlineAndAllOf_mostRestrictiveWins() { OpenAPI openAPI = TestUtils.createOpenAPI(); - // allOf item has minimum=20, which is more restrictive than inline minimum=0 Schema allOfItem = new IntegerSchema(); allOfItem.setMinimum(BigDecimal.valueOf(20)); openAPI.getComponents().addSchemas("Base", allOfItem); @@ -899,16 +1081,18 @@ public void resolveMinimum_inlineAndAllOf_mostRestrictiveWins() { Schema schema = new IntegerSchema(); schema.setMinimum(BigDecimal.valueOf(0)); schema.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); - assertEquals(ModelUtils.resolveMinimum(openAPI, schema), BigDecimal.valueOf(20)); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(20), bound.minBound); } @Test - public void resolveMinimum_allOfItemWithoutMinimum_ignored() { + 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.resolveMinimum(openAPI, schema)); + assertNull(ModelUtils.resolveMinimumBound(openAPI, schema)); } // ------------------------------------------------------------------------- From 86992bf4cb81ab3a255b151c982bdd5b1806a08a Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 7 May 2026 13:24:33 +0200 Subject: [PATCH 16/43] DRY code --- .../codegen/utils/ModelUtils.java | 79 +++++++------------ 1 file changed, 30 insertions(+), 49 deletions(-) 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 89fc6e43ebf6..37e72a6dfa16 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 @@ -720,7 +720,7 @@ 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) { @@ -982,22 +982,17 @@ private static ResolvedMinBound extractMinBound(Schema schema) { */ @Nullable public static ResolvedMaxBound resolveMaximumBound(OpenAPI openAPI, Schema schema) { - if (schema == null) return null; - - if (schema.get$ref() != null) { - schema = getReferencedSchema(openAPI, schema); - } + schema = getReferencedSchema(openAPI, schema); if (schema == null) return null; ResolvedMaxBound result = extractMaxBound(schema); - List allOf = schema.getAllOf(); - if (allOf == null) return result; - - return allOf.stream() - .map(allOfItem -> getReferencedSchema(openAPI, allOfItem)) - .filter(Objects::nonNull) - .map(ModelUtils::extractMaxBound) - .reduce(result, ResolvedMaxBound::getSmallerMaxBound); + return !hasAllOf(schema) + ? result + : schema.getAllOf().stream() + .map(allOfItem -> getReferencedSchema(openAPI, allOfItem)) + .filter(Objects::nonNull) + .map(ModelUtils::extractMaxBound) + .reduce(result, ResolvedMaxBound::getSmallerMaxBound); } /** @@ -1014,22 +1009,17 @@ public static ResolvedMaxBound resolveMaximumBound(OpenAPI openAPI, Schema sc */ @Nullable public static ResolvedMinBound resolveMinimumBound(OpenAPI openAPI, Schema schema) { - if (schema == null) return null; - - if (schema.get$ref() != null) { - schema = getReferencedSchema(openAPI, schema); - } + schema = getReferencedSchema(openAPI, schema); if (schema == null) return null; ResolvedMinBound result = extractMinBound(schema); - List allOf = schema.getAllOf(); - if (allOf == null) return result; - - return allOf.stream() - .map(allOfItem -> getReferencedSchema(openAPI, allOfItem)) - .filter(Objects::nonNull) - .map(ModelUtils::extractMinBound) - .reduce(result, ResolvedMinBound::getLargerMinBound); + return !hasAllOf(schema) + ? result + : schema.getAllOf().stream() + .map(allOfItem -> getReferencedSchema(openAPI, allOfItem)) + .filter(Objects::nonNull) + .map(ModelUtils::extractMinBound) + .reduce(result, ResolvedMinBound::getLargerMinBound); } /** @@ -1043,32 +1033,23 @@ public static ResolvedMinBound resolveMinimumBound(OpenAPI openAPI, Schema sc * @return the effective default value, or {@code null} if none is defined */ public static Object resolveDefault(OpenAPI openAPI, Schema schema) { - if (schema == null) { - return null; - } - - Schema resolvedSchema = getReferencedSchema(openAPI, schema); - if (resolvedSchema == null) { - return null; - } + schema = getReferencedSchema(openAPI, schema); + if (schema == null) return null; - Object defaultValue = resolvedSchema.getDefault(); + Object defaultValue = schema.getDefault(); if (defaultValue != null) { + // inline default value takes precedence return defaultValue; } - - List allOf = resolvedSchema.getAllOf(); - if (allOf == null) { - return null; - } - - return allOf.stream() - .map(item -> getReferencedSchema(openAPI, item)) - .filter(Objects::nonNull) - .map(Schema::getDefault) - .filter(Objects::nonNull) - .findFirst() - .orElse(null); + return !hasAllOf(schema) + ? null + : schema.getAllOf().stream() + .map(item -> getReferencedSchema(openAPI, item)) + .filter(Objects::nonNull) + .map(Schema::getDefault) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); } public static boolean hasValidation(Schema sc) { From 25f1f8fc411ce41458e934cf2344f1d15230b63a Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 7 May 2026 13:49:51 +0200 Subject: [PATCH 17/43] refactor: improve resolution of nested allOf constraints for maximum and minimum bounds --- .../codegen/utils/ModelUtils.java | 10 +-- .../codegen/utils/ModelUtilsTest.java | 86 +++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) 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 37e72a6dfa16..7e2c474edf2b 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 @@ -989,9 +989,8 @@ public static ResolvedMaxBound resolveMaximumBound(OpenAPI openAPI, Schema sc return !hasAllOf(schema) ? result : schema.getAllOf().stream() - .map(allOfItem -> getReferencedSchema(openAPI, allOfItem)) - .filter(Objects::nonNull) - .map(ModelUtils::extractMaxBound) + // recursive search for smallest max bound + .map(allOfItem -> resolveMaximumBound(openAPI, allOfItem)) .reduce(result, ResolvedMaxBound::getSmallerMaxBound); } @@ -1016,9 +1015,8 @@ public static ResolvedMinBound resolveMinimumBound(OpenAPI openAPI, Schema sc return !hasAllOf(schema) ? result : schema.getAllOf().stream() - .map(allOfItem -> getReferencedSchema(openAPI, allOfItem)) - .filter(Objects::nonNull) - .map(ModelUtils::extractMinBound) + // recursive search for largest min bound + .map(allOfItem -> resolveMinimumBound(openAPI, allOfItem)) .reduce(result, ResolvedMinBound::getLargerMinBound); } 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 0acb4d4a7e94..386b2307b6ce 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 @@ -928,6 +928,49 @@ public void resolveMaximumBound_allOfItemWithoutMaximum_ignored() { 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 // ------------------------------------------------------------------------- @@ -1095,6 +1138,49 @@ public void resolveMinimumBound_allOfItemWithoutMinimum_ignored() { 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)); + } + // ------------------------------------------------------------------------- // resolveDefault // ------------------------------------------------------------------------- From 4c7032c60c16d642dce903e349df764675b6030e Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 7 May 2026 14:02:15 +0200 Subject: [PATCH 18/43] re-enable test --- .../codegen/kotlin/spring/KotlinSpringServerCodegenTest.java | 1 + 1 file changed, 1 insertion(+) 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 0e3f0c4c5b1b..243c20c62317 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 @@ -6013,6 +6013,7 @@ public void substituteGenericPagedModel_withModelNameSuffix_suppressesPagedSchem } + @Test(description = "oneOf with discriminator generates thin sealed interface with Jackson annotations") public void testOneOfWithDiscriminatorGeneratesThinInterface() throws IOException { File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); output.deleteOnExit(); From ea9937a002a2f793734a4d1a152314e61052cffb Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 7 May 2026 19:03:00 +0200 Subject: [PATCH 19/43] test: fix implementation and add unit test for resolving default in nested allOf schemas --- .../codegen/utils/ModelUtils.java | 40 ++++++++++++------- .../codegen/utils/ModelUtilsTest.java | 16 ++++++++ 2 files changed, 41 insertions(+), 15 deletions(-) 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 7e2c474edf2b..05547d317746 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 @@ -583,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; } @@ -726,7 +726,7 @@ public static boolean isDateTimeLocalSchema(Schema schema) { 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) { @@ -845,12 +845,12 @@ 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()) ); } @@ -989,7 +989,7 @@ public static ResolvedMaxBound resolveMaximumBound(OpenAPI openAPI, Schema sc return !hasAllOf(schema) ? result : schema.getAllOf().stream() - // recursive search for smallest max bound + // recursive search for smallest max bound .map(allOfItem -> resolveMaximumBound(openAPI, allOfItem)) .reduce(result, ResolvedMaxBound::getSmallerMaxBound); } @@ -1022,9 +1022,20 @@ public static ResolvedMinBound resolveMinimumBound(OpenAPI openAPI, Schema sc /** * Returns the effective {@code default} for the given schema, resolving through a top-level - * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). - * Unlike constraints, the inline schema's default takes precedence (explicit per-endpoint - * override); falls back to the first non-null default found in {@code allOf} items. + * {@code $ref} and recursively walking any {@code allOf} items. + * The inline schema's default takes precedence (explicit per-endpoint override); + * falls back to the first non-null default found via depth-first search of {@code allOf} items. + * Circular {@code allOf} references are detected and skipped. + * + * @param openAPI the OpenAPI document used to resolve {@code $ref}s + * @param schema the schema to inspect + * @return the effective default value, or {@code null} if none is defined + */ + /** + * Returns the effective {@code default} for the given schema, resolving through a top-level + * {@code $ref} and recursively walking any {@code allOf} items. + * The inline schema's default takes precedence (explicit per-endpoint override); + * falls back to the first non-null default found via depth-first search of {@code allOf} items. * * @param openAPI the OpenAPI document used to resolve {@code $ref}s * @param schema the schema to inspect @@ -1042,9 +1053,8 @@ public static Object resolveDefault(OpenAPI openAPI, Schema schema) { return !hasAllOf(schema) ? null : schema.getAllOf().stream() - .map(item -> getReferencedSchema(openAPI, item)) - .filter(Objects::nonNull) - .map(Schema::getDefault) + // recursive search for default + .map(item -> resolveDefault(openAPI, item)) .filter(Objects::nonNull) .findFirst() .orElse(null); 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 386b2307b6ce..c9d45e598941 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 @@ -1257,4 +1257,20 @@ public void resolveDefault_stringDefault_returnsIt() { schema.setDefault("hello"); assertEquals(ModelUtils.resolveDefault(openAPI, schema), "hello"); } + + @Test + public void resolveDefault_nestedAllOf_findsDefaultInNestedItem() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // base has default=99; mid allOf → base; top allOf → mid + Schema base = new IntegerSchema(); + base.setDefault(99); + openAPI.getComponents().addSchemas("Base", base); + + Schema mid = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + openAPI.getComponents().addSchemas("Mid", mid); + + Schema top = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Mid"))); + + assertEquals(ModelUtils.resolveDefault(openAPI, top), 99); + } } From 4c76d10dbe88c294d4c4e99a81b152bbe8e4e02b Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 7 May 2026 19:19:10 +0200 Subject: [PATCH 20/43] add comment to explain that behavior around default is undefined in open api spec --- .../main/java/org/openapitools/codegen/utils/ModelUtils.java | 2 ++ 1 file changed, 2 insertions(+) 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 05547d317746..78fed854c3b3 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 @@ -1056,6 +1056,8 @@ public static Object resolveDefault(OpenAPI openAPI, Schema schema) { // recursive search for default .map(item -> resolveDefault(openAPI, item)) .filter(Objects::nonNull) + // first non-null default in allOf wins. + // This is very arbitrary and might not be correct behavior, since behavior regarding default inheritance/overriding is unspecified .findFirst() .orElse(null); } From 5e85d534049c5b67ba8eeeea81d9aa72ad6d1a97 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 15 May 2026 00:25:26 +0200 Subject: [PATCH 21/43] pageable-scan-resolve-allof-constraints refactor: streamline pageable handling and enhance annotation application logic --- .../languages/KotlinSpringServerCodegen.java | 136 +----- .../codegen/languages/SpringCodegen.java | 123 ++---- .../languages/SpringPageableScanUtils.java | 410 ++++++++++++++++-- .../codegen/utils/ModelUtils.java | 26 +- .../SpringPageableScanUtilsTest.java | 324 ++++++++++++++ .../org/openapitools/api/PetController.java | 2 - .../java/org/openapitools/api/PetApi.java | 14 +- 7 files changed, 782 insertions(+), 253 deletions(-) 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 dbd241a68f5a..30843f29a948 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 @@ -197,20 +197,14 @@ 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"; + // Holds scan results for Spring Pageable features (populated during preprocessOpenAPI) + private final SpringPageableScanUtils pageableUtils = new SpringPageableScanUtils(); + public KotlinSpringServerCodegen() { super(); @@ -1038,34 +1032,16 @@ 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 and set x-spring-paginated if autoXSpringPaginated is enabled. + // Must be done BEFORE super.fromOperation() so that the base codegen populates + // codegenOperation.vendorExtensions from the extension we just set on 'operation'. + // Only for spring-boot library; respect manual x-spring-paginated: false override. + if (SPRING_BOOT.equals(library)) { + SpringPageableScanUtils.applyAutoXSpringPaginatedIfNeeded(operation, autoXSpringPaginated); + } 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)) { @@ -1078,75 +1054,16 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation // 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); - } + SpringPageableScanUtils.applySpringDocPageableAnnotation(codegenOperation, + SpringPageableScanUtils.AnnotationSyntax.KOTLIN, + DocumentationProvider.SPRINGDOC.equals(getDocumentationProvider())); // #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); - 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(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); - - 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)); + // Build and attach pageable parameter annotations + SpringPageableScanUtils.removePageableQueryParams(codegenOperation); + pageableUtils.applyPageableAnnotations(codegenOperation, + generatePageableConstraintValidation, useBeanValidation, + generateSortValidation, SpringPageableScanUtils.AnnotationSyntax.KOTLIN); } } @@ -1191,27 +1108,22 @@ 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()) { + if (SPRING_BOOT.equals(library)) { + pageableUtils.scanAll(openAPI, autoXSpringPaginated); + + if (generateSortValidation && useBeanValidation && !pageableUtils.sortValidationEnums.isEmpty()) { importMapping.putIfAbsent("ValidSort", configPackage + ".ValidSort"); supportingFiles.add(new SupportingFile("validSort.mustache", (sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidSort.kt")); } - } - if (SPRING_BOOT.equals(library)) { - pageableDefaultsRegistry = SpringPageableScanUtils.scanPageableDefaults(openAPI, autoXSpringPaginated); - if (!pageableDefaultsRegistry.isEmpty()) { + if (!pageableUtils.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"); } - } - if (SPRING_BOOT.equals(library) && generatePageableConstraintValidation && useBeanValidation) { - pageableConstraintsRegistry = SpringPageableScanUtils.scanPageableConstraints(openAPI, autoXSpringPaginated); - if (!pageableConstraintsRegistry.isEmpty()) { + if (generatePageableConstraintValidation && useBeanValidation && !pageableUtils.pageableConstraintsRegistry.isEmpty()) { importMapping.putIfAbsent("ValidPageable", configPackage + ".ValidPageable"); supportingFiles.add(new SupportingFile("validPageable.mustache", (sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidPageable.kt")); 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 d9952c925fca..b51115ef4fcd 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 @@ -197,17 +197,14 @@ public enum RequestMappingMode { @Setter protected boolean generatePageableConstraintValidation = false; @Setter protected boolean substituteGenericPagedModel = 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"; + // Holds scan results for Spring Pageable features (populated during preprocessOpenAPI) + private final SpringPageableScanUtils pageableUtils = new SpringPageableScanUtils(); + public SpringCodegen() { super(); @@ -843,27 +840,22 @@ 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()) { + if (SPRING_BOOT.equals(library)) { + pageableUtils.scanAll(openAPI, autoXSpringPaginated); + + if (generateSortValidation && useBeanValidation && !pageableUtils.sortValidationEnums.isEmpty()) { importMapping.putIfAbsent("ValidSort", configPackage + ".ValidSort"); supportingFiles.add(new SupportingFile("validSort.mustache", (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "ValidSort.java")); } - } - if (SPRING_BOOT.equals(library)) { - pageableDefaultsRegistry = SpringPageableScanUtils.scanPageableDefaults(openAPI, autoXSpringPaginated); - if (!pageableDefaultsRegistry.isEmpty()) { + if (!pageableUtils.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"); } - } - if (SPRING_BOOT.equals(library) && generatePageableConstraintValidation && useBeanValidation) { - pageableConstraintsRegistry = SpringPageableScanUtils.scanPageableConstraints(openAPI, autoXSpringPaginated); - if (!pageableConstraintsRegistry.isEmpty()) { + if (generatePageableConstraintValidation && useBeanValidation && !pageableUtils.pageableConstraintsRegistry.isEmpty()) { importMapping.putIfAbsent("ValidPageable", configPackage + ".ValidPageable"); supportingFiles.add(new SupportingFile("validPageable.mustache", (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "ValidPageable.java")); @@ -1216,27 +1208,19 @@ protected boolean isConstructorWithAllArgsAllowed(CodegenModel codegenModel) { @Override public CodegenOperation fromOperation(String path, String httpMethod, Operation operation, List servers) { - // Auto-detect pagination parameters and add x-spring-paginated if autoXSpringPaginated is enabled. + // Auto-detect pagination parameters and set x-spring-paginated if autoXSpringPaginated is enabled. + // Must be done BEFORE super.fromOperation() so that the base codegen populates + // codegenOperation.vendorExtensions from the extension we just set on 'operation'. // 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); - } - } - } + if (SPRING_BOOT.equals(library)) { + SpringPageableScanUtils.applyAutoXSpringPaginatedIfNeeded(operation, autoXSpringPaginated); } - // 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"))) { + // 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"); } @@ -1248,68 +1232,21 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation 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")) { + // Only for spring-boot: client libraries (spring-cloud, spring-declarative-http-interface) + // need actual query parameters for HTTP calls, so x-spring-paginated is ignored for them. + if (SPRING_BOOT.equals(library) && 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") - ); + SpringPageableScanUtils.applySpringDocPageableAnnotation(codegenOperation, + SpringPageableScanUtils.AnnotationSyntax.JAVA, + DocumentationProvider.SPRINGDOC.equals(getDocumentationProvider())); // #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); - 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(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"); - } + SpringPageableScanUtils.removePageableQueryParams(codegenOperation); - 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 and attach pageable parameter annotations + pageableUtils.applyPageableAnnotations(codegenOperation, + generatePageableConstraintValidation, useBeanValidation, + generateSortValidation, SpringPageableScanUtils.AnnotationSyntax.JAVA); } if (codegenOperation.vendorExtensions.containsKey("x-spring-provide-args") && !provideArgsClassSet.isEmpty()) { codegenOperation.imports.addAll(provideArgsClassSet); 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 a8a8173e47c3..8dd5fe888a20 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"; + + /** + * 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<>(); - private SpringPageableScanUtils() {} + public SpringPageableScanUtils() {} // ------------------------------------------------------------------------- // Data classes @@ -96,6 +132,43 @@ public boolean hasAny() { } } + // ------------------------------------------------------------------------- + // 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 // ------------------------------------------------------------------------- @@ -120,11 +193,266 @@ public static boolean willBePageable(Operation operation, boolean autoXSpringPag Set paramNames = operation.getParameters().stream() .map(Parameter::getName) .collect(Collectors.toSet()); - return paramNames.containsAll(Arrays.asList("page", "size", "sort")); + return paramNames.containsAll(DEFAULT_PAGEABLE_QUERY_PARAMS); + } + return false; + } + + /** + * Auto-detects Pageable pagination query parameters and, when detected, mutates the + * operation by setting {@code x-spring-paginated: true} on its vendor extensions. + * + *

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}.

+ * + *

Rules (in priority order):

+ *
    + *
  1. If {@code x-spring-paginated} is explicitly {@code false} → do nothing, return {@code false}.
  2. + *
  3. If {@code x-spring-paginated} is already {@code true} → return {@code true} without re-mutating.
  4. + *
  5. If {@code autoXSpringPaginated} is {@code true} and the operation has all three + * {@link #DEFAULT_PAGEABLE_QUERY_PARAMS} ({@code page}, {@code size}, {@code sort}) + * → set {@code x-spring-paginated: true} and return {@code true}.
  6. + *
  7. Otherwise → return {@code false}.
  8. + *
+ * + * @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 (operation.getExtensions() != null) { + Object paginated = operation.getExtensions().get("x-spring-paginated"); + if (Boolean.FALSE.equals(paginated)) { + return false; + } + if (Boolean.TRUE.equals(paginated)) { + return true; + } + } + if (autoXSpringPaginated && operation.getParameters() != null) { + Set paramNames = operation.getParameters().stream() + .map(Parameter::getName) + .collect(Collectors.toSet()); + if (paramNames.containsAll(DEFAULT_PAGEABLE_QUERY_PARAMS)) { + if (operation.getExtensions() == null) { + operation.setExtensions(new HashMap<>()); + } + operation.getExtensions().put("x-spring-paginated", Boolean.TRUE); + return true; + } } return false; } + /** + * 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 (!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 (!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); + } + } + + @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. * @@ -139,14 +467,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(); @@ -196,10 +523,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; @@ -216,17 +542,17 @@ public static Map scanPageableDefaults( continue; } 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); @@ -275,10 +601,9 @@ 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; @@ -292,20 +617,22 @@ public static Map scanPageableConstraints( } ModelUtils.ResolvedMaxBound maxBound = ModelUtils.resolveMaximumBound(openAPI, schema); ModelUtils.ResolvedMinBound minBound = ModelUtils.resolveMinimumBound(openAPI, schema); - Integer adjustedMaxBound = maxBound == null - ? null - : (maxBound.exclusive ? maxBound.maxBound.intValue() - 1 : maxBound.maxBound.intValue()); - Integer adjustedMinBound = minBound == null - ? null - : (minBound.exclusive ? minBound.minBound.intValue() + 1 : minBound.minBound.intValue()); switch (param.getName()) { - case "page": - if (adjustedMaxBound != null) maxPage = adjustedMaxBound; - if (adjustedMinBound != null) minPage = adjustedMinBound; + case PAGE: + if (maxBound != null) { + maxPage = toIntInclusiveMax(maxBound); + } + if (minBound != null) { + minPage = toIntInclusiveMin(minBound); + } break; - case "size": - if (adjustedMaxBound != null) maxSize = adjustedMaxBound; - if (adjustedMinBound != null) minSize = adjustedMinBound; + case SIZE: + if (maxBound != null) { + maxSize = toIntInclusiveMax(maxBound); + } + if (minBound != null) { + minSize = toIntInclusiveMin(minBound); + } break; default: break; @@ -320,4 +647,19 @@ 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/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index 78fed854c3b3..4e261ae471da 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 @@ -1050,16 +1050,22 @@ public static Object resolveDefault(OpenAPI openAPI, Schema schema) { // inline default value takes precedence return defaultValue; } - return !hasAllOf(schema) - ? null - : schema.getAllOf().stream() - // recursive search for default - .map(item -> resolveDefault(openAPI, item)) - .filter(Objects::nonNull) - // first non-null default in allOf wins. - // This is very arbitrary and might not be correct behavior, since behavior regarding default inheritance/overriding is unspecified - .findFirst() - .orElse(null); + if (hasAllOf(schema)) { + return getFirstNonNullDefault(openAPI, schema).orElse(null); + } + return null; + } + + /** + * Recursively searches {@code allOf} items for the first non-null {@code default} value. + * The first non-null default in {@code allOf} wins. + * This behavior is arbitrary and may not reflect intentions of the spec creator, as default's inheritance/overriding behavior is undefined. + */ + private static @NonNull Optional getFirstNonNullDefault(OpenAPI openAPI, Schema schema) { + return schema.getAllOf().stream() + .map(item -> resolveDefault(openAPI, item)) + .filter(Objects::nonNull) + .findFirst(); } public static boolean hasValidation(Schema sc) { 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 index a2e7136a8259..e42586035c83 100644 --- 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 @@ -9,9 +9,13 @@ 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; @@ -275,4 +279,324 @@ public void scanSortValidationEnums_sortSchemaWithNoEnum_returnsEmptyMap() { 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/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..1e637f43a1a8 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; 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..4949b2dae998 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; @@ -140,6 +138,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,6 +171,9 @@ 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, + @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, @ParameterObject final Pageable pageable ); @@ -217,6 +221,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,6 +250,9 @@ ResponseEntity getPetById( ) @org.springframework.validation.annotation.Validated ResponseEntity> listAllPets( + @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, @ParameterObject final Pageable pageable ); From 5eba33355e9e33266bc69f593e0401060ea22e25 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 15 May 2026 01:47:39 +0200 Subject: [PATCH 22/43] feat: add support for ValuedEnum interface in code generation for enums --- docs/generators/java-camel.md | 1 + docs/generators/kotlin-spring.md | 1 + docs/generators/spring.md | 1 + .../codegen/CodegenConstants.java | 7 + .../languages/EnumValueInterfaceUtils.java | 134 ++++++++++++++++++ .../languages/KotlinSpringServerCodegen.java | 23 ++- .../codegen/languages/SpringCodegen.java | 21 ++- .../JavaSpring/enumValueInterface.mustache | 6 + .../kotlin-spring/dataClass.mustache | 2 +- .../kotlin-spring/enumClass.mustache | 2 +- .../kotlin-spring/enumValueInterface.mustache | 6 + .../java/spring/SpringCodegenTest.java | 75 +++++++++- .../spring/KotlinSpringServerCodegenTest.java | 73 ++++++++++ .../3_0/spring/enum-value-interface.yaml | 42 ++++++ 14 files changed, 385 insertions(+), 9 deletions(-) create mode 100644 modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/EnumValueInterfaceUtils.java create mode 100644 modules/openapi-generator/src/main/resources/JavaSpring/enumValueInterface.mustache create mode 100644 modules/openapi-generator/src/main/resources/kotlin-spring/enumValueInterface.mustache create mode 100644 modules/openapi-generator/src/test/resources/3_0/spring/enum-value-interface.yaml diff --git a/docs/generators/java-camel.md b/docs/generators/java-camel.md index 2ac49f9d6eaf..9b007dbf9103 100644 --- a/docs/generators/java-camel.md +++ b/docs/generators/java-camel.md @@ -111,6 +111,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| diff --git a/docs/generators/kotlin-spring.md b/docs/generators/kotlin-spring.md index 584c4e1c9cb3..3d716858bc5e 100644 --- a/docs/generators/kotlin-spring.md +++ b/docs/generators/kotlin-spring.md @@ -62,6 +62,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. Incompatible with `openApiNullable`.| |false| diff --git a/docs/generators/spring.md b/docs/generators/spring.md index 0542bced8084..a247d7accebd 100644 --- a/docs/generators/spring.md +++ b/docs/generators/spring.md @@ -104,6 +104,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| 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 7dd1947471c1..46cc8010ef71 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 @@ -489,6 +489,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/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/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index 30843f29a948..ea9c15b08590 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.*; @@ -181,6 +178,8 @@ public String getDescription() { @Setter private boolean substituteGenericPagedModel = false; @Setter private boolean useSealedResponseInterfaces = false; @Setter private boolean companionObject = false; + @Setter private boolean useEnumValueInterface = false; + private String valuedEnumClassName = "ValuedEnum"; @Getter @Setter protected boolean useDeductionForOneOfInterfaces = false; @@ -308,6 +307,7 @@ public KotlinSpringServerCodegen() { substituteGenericPagedModel); addSwitch(COMPANION_OBJECT, "Whether to generate companion objects in data classes, enabling companion extensions.", companionObject); 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); supportedLibraries.put(SPRING_BOOT, "Spring-boot Server application."); supportedLibraries.put(SPRING_CLOUD_LIBRARY, "Spring-Cloud-Feign client with Spring-Boot auto-configured settings."); @@ -753,6 +753,10 @@ public void processOpts() { this.setSubstituteGenericPagedModel(convertPropertyToBoolean(SUBSTITUTE_GENERIC_PAGED_MODEL)); } writePropertyBack(SUBSTITUTE_GENERIC_PAGED_MODEL, substituteGenericPagedModel); + if (additionalProperties.containsKey(CodegenConstants.USE_ENUM_VALUE_INTERFACE)) { + this.setUseEnumValueInterface(convertPropertyToBoolean(CodegenConstants.USE_ENUM_VALUE_INTERFACE)); + } + writePropertyBack(CodegenConstants.USE_ENUM_VALUE_INTERFACE, useEnumValueInterface); if (isUseSpringBoot3() && isUseSpringBoot4()) { throw new IllegalArgumentException("Choose between Spring Boot 3 and Spring Boot 4"); } @@ -1154,6 +1158,13 @@ public void preprocessOpenAPI(OpenAPI openAPI) { } } + if (useEnumValueInterface) { + valuedEnumClassName = EnumValueInterfaceUtils.setupInPreprocessOpenAPI( + importMapping, additionalProperties, supportingFiles, + sourceFolder, configPackage, + "enumValueInterface.mustache", "ValuedEnum.kt"); + } + if (!additionalProperties.containsKey(TITLE)) { // The purpose of the title is for: // - README documentation @@ -1457,6 +1468,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/SpringCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java index b51115ef4fcd..0803f5e19eb6 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 @@ -196,6 +196,8 @@ public enum RequestMappingMode { @Setter protected boolean generateSortValidation = false; @Setter protected boolean generatePageableConstraintValidation = false; @Setter protected boolean substituteGenericPagedModel = false; + @Setter protected boolean useEnumValueInterface = false; + private String valuedEnumClassName = "ValuedEnum"; // Map from schema name to detected paged-model info (populated when substituteGenericPagedModel=true) private Map pagedModelRegistry = new HashMap<>(); @@ -378,6 +380,9 @@ public SpringCodegen() { + "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)); + cliOptions.add(CliOption.newBoolean(CodegenConstants.USE_ENUM_VALUE_INTERFACE, + CodegenConstants.USE_ENUM_VALUE_INTERFACE_DESC, + useEnumValueInterface)); } @@ -588,6 +593,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); @@ -886,6 +892,13 @@ public void preprocessOpenAPI(OpenAPI openAPI) { } } + if (useEnumValueInterface) { + valuedEnumClassName = EnumValueInterfaceUtils.setupInPreprocessOpenAPI( + importMapping, additionalProperties, supportingFiles, + sourceFolder, configPackage, + "enumValueInterface.mustache", "ValuedEnum.java"); + } + /* * TODO the following logic should not need anymore in OAS 3.0 if * ("/".equals(swagger.getBasePath())) { swagger.setBasePath(""); } @@ -1442,7 +1455,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")); @@ -1455,6 +1468,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/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/kotlin-spring/dataClass.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache index 1c7b263517fe..7391dbc29b30 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}}override {{/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/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index f9d37a199c66..8c6977a088f7 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 @@ -67,9 +67,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 { @@ -7837,4 +7835,75 @@ void disableDiscriminatorJsonIgnorePropertiesIsTrueThenJsonIgnorePropertiesShoul JavaFileAssert.assertThat(files.get("BaseConfiguration.java")) .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"); + } } 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 cb2e9545272e..ad9489ba06a7 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.*; @@ -6231,4 +6232,76 @@ public void testSealedResponseInterfacesWithDeclarativeHttpInterface() throws IO "fun getUser(", "): 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 useEnumValueInterface_generatesInterface() throws IOException { + Map files = generateFromContract( + "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 + .addImportMapping("ValuedEnum", "com.example.custom.ValuedEnum")); + + assertFileContains(files.get("OrderStatus.kt").toPath(), + ": ValuedEnum", + "import com.example.custom.ValuedEnum"); + } } 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 From 772ff0ecfb1b63c05a96679e9285bcd4eaa7bb87 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 15 May 2026 09:04:40 +0200 Subject: [PATCH 23/43] fix: remove Pageable support for non-spring-boot libraries and update related documentation --- .../openapitools/codegen/VendorExtension.java | 2 +- .../languages/KotlinSpringServerCodegen.java | 10 ++++++ .../codegen/languages/SpringCodegen.java | 11 ++++-- .../java/spring/SpringCodegenTest.java | 28 +++++++++++++++ .../spring/KotlinSpringServerCodegenTest.java | 34 +++++++++++++++++++ .../org/openapitools/api/PetController.java | 6 ++-- .../java/org/openapitools/api/PetApi.java | 9 ++--- 7 files changed, 87 insertions(+), 13 deletions(-) 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/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index ea9c15b08590..8736ca361d9b 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 @@ -1046,6 +1046,16 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation CodegenOperation codegenOperation = super.fromOperation(path, httpMethod, operation, servers); + // 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, and log it. + if (!SPRING_BOOT.equals(library) && 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, library); + } + // 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)) { 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 0803f5e19eb6..4c3ba5d05039 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 @@ -1244,9 +1244,16 @@ 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")); + // For client libraries (spring-cloud, spring-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 @ParameterObject Pageable, and log it. + if (!SPRING_BOOT.equals(library) && 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, library); + } // add org.springframework.data.domain.Pageable import when needed - // Only for spring-boot: client libraries (spring-cloud, spring-declarative-http-interface) - // need actual query parameters for HTTP calls, so x-spring-paginated is ignored for them. if (SPRING_BOOT.equals(library) && codegenOperation.vendorExtensions.containsKey("x-spring-paginated")) { codegenOperation.imports.add("Pageable"); SpringPageableScanUtils.applySpringDocPageableAnnotation(codegenOperation, 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 8c6977a088f7..1bd6f60d5742 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 @@ -6884,6 +6884,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<>(); 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 ad9489ba06a7..761e9fefe52b 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 @@ -4899,6 +4899,40 @@ public void autoXSpringPaginatedOnlyForSpringBoot() throws Exception { } } + @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<>(); 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 1e637f43a1a8..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 @@ -125,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 ); @@ -164,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 4949b2dae998..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 @@ -127,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 ); @@ -174,8 +173,7 @@ ResponseEntity> findPetsByTags( @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, - @ParameterObject final Pageable pageable + @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 ); @@ -252,8 +250,7 @@ ResponseEntity getPetById( ResponseEntity> listAllPets( @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, - @ParameterObject final Pageable pageable + @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 ); From 011c7b737ba32ce25b4a109446a33fe84221f9fc Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 15 May 2026 09:06:15 +0200 Subject: [PATCH 24/43] update documentation --- docs/generators/java-camel.md | 2 +- docs/generators/kotlin-spring.md | 2 +- docs/generators/spring.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/generators/java-camel.md b/docs/generators/java-camel.md index 9b007dbf9103..258a45450124 100644 --- a/docs/generators/java-camel.md +++ b/docs/generators/java-camel.md @@ -145,7 +145,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 3d716858bc5e..090949dc1f72 100644 --- a/docs/generators/kotlin-spring.md +++ b/docs/generators/kotlin-spring.md @@ -91,7 +91,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 a247d7accebd..5af466de0cd9 100644 --- a/docs/generators/spring.md +++ b/docs/generators/spring.md @@ -138,7 +138,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 From 90282eab37170eb2a57627a2d524ccfeb3161aba Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 15 May 2026 12:30:36 +0200 Subject: [PATCH 25/43] fix: update default resolution logic for allOf schemas to last-writer-wins semantics --- .../codegen/utils/ModelUtils.java | 76 ++++++++++++------- .../codegen/utils/ModelUtilsTest.java | 71 +++++++++++++++++ 2 files changed, 119 insertions(+), 28 deletions(-) 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 4e261ae471da..2c40a8217c6a 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 @@ -1021,21 +1021,34 @@ public static ResolvedMinBound resolveMinimumBound(OpenAPI openAPI, Schema sc } /** - * Returns the effective {@code default} for the given schema, resolving through a top-level - * {@code $ref} and recursively walking any {@code allOf} items. - * The inline schema's default takes precedence (explicit per-endpoint override); - * falls back to the first non-null default found via depth-first search of {@code allOf} items. - * Circular {@code allOf} references are detected and skipped. - * - * @param openAPI the OpenAPI document used to resolve {@code $ref}s - * @param schema the schema to inspect - * @return the effective default value, or {@code null} if none is defined - */ - /** - * Returns the effective {@code default} for the given schema, resolving through a top-level - * {@code $ref} and recursively walking any {@code allOf} items. - * The inline schema's default takes precedence (explicit per-endpoint override); - * falls back to the first non-null default found via depth-first search of {@code allOf} items. + * Returns the effective {@code default} for the given schema using + * last-writer-wins semantics across the flattened {@code allOf} chain. + * + *

Resolution algorithm

+ *
    + *
  1. Resolve any top-level {@code $ref} to obtain the concrete schema.
  2. + *
  3. If the schema has a direct {@code default} (i.e. the {@code default:} key at + * the same level as {@code allOf:}), return it immediately — no traversal needed.
  4. + *
  5. Otherwise walk the {@code allOf} array top-to-bottom. Each + * item is itself fully resolved (recursing into nested {@code allOf} chains), and + * its result — if non-null — overwrites the current candidate (last-writer-wins).
  6. + *
+ * + *

Example

+ *
{@code
+     * # Base1: default = "base_1"   → Step 1: candidate = "base_1"
+     * # Base2: default = "base_2"   → Step 2: candidate = "base_2"  (overwrites Step 1)
+     * #
+     * # Intermediate:
+     * #   allOf: [$ref Base1, $ref Base2]
+     * #   → resolves to "base_2"  (Base2 is last, wins over Base1)
+     * #
+     * # Final:
+     * #   allOf:
+     * #     - $ref: Intermediate    → Step 3a: candidate = "base_2"
+     * #     - default: "final"      → Step 3b: candidate = "final"  (overwrites Step 3a)
+     * #   → resolves to "final"
+     * }
* * @param openAPI the OpenAPI document used to resolve {@code $ref}s * @param schema the schema to inspect @@ -1045,27 +1058,34 @@ public static Object resolveDefault(OpenAPI openAPI, Schema schema) { schema = getReferencedSchema(openAPI, schema); if (schema == null) return null; - Object defaultValue = schema.getDefault(); - if (defaultValue != null) { - // inline default value takes precedence - return defaultValue; + // Direct default short-circuits — no allOf traversal needed. + Object directDefault = schema.getDefault(); + if (directDefault != null) { + return directDefault; } + + // Walk allOf top-to-bottom; each non-null result overwrites the previous candidate. if (hasAllOf(schema)) { - return getFirstNonNullDefault(openAPI, schema).orElse(null); + return getLastNonNullDefault(openAPI, schema); } return null; } /** - * Recursively searches {@code allOf} items for the first non-null {@code default} value. - * The first non-null default in {@code allOf} wins. - * This behavior is arbitrary and may not reflect intentions of the spec creator, as default's inheritance/overriding behavior is undefined. + * Walks {@code allOf} items top-to-bottom and returns the last non-null + * {@code default} value found (last-writer-wins). Each item is fully resolved + * (including its own nested {@code allOf}) before the candidate is updated, so + * arbitrarily deep chains are flattened correctly. */ - private static @NonNull Optional getFirstNonNullDefault(OpenAPI openAPI, Schema schema) { - return schema.getAllOf().stream() - .map(item -> resolveDefault(openAPI, item)) - .filter(Objects::nonNull) - .findFirst(); + private static Object getLastNonNullDefault(OpenAPI openAPI, Schema schema) { + Object last = null; + for (Schema item : schema.getAllOf()) { + Object resolved = resolveDefault(openAPI, item); + if (resolved != null) { + last = resolved; + } + } + return last; } public static boolean hasValidation(Schema sc) { 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 c9d45e598941..c9259421bb8a 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 @@ -1273,4 +1273,75 @@ public void resolveDefault_nestedAllOf_findsDefaultInNestedItem() { assertEquals(ModelUtils.resolveDefault(openAPI, top), 99); } + + @Test + public void resolveDefault_allOf_lastDefaultWins() { + // allOf: [Base1(default="base_1"), Base2(default="base_2")] + // Base2 is last → wins + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema base1 = new StringSchema(); + base1.setDefault("base_1"); + openAPI.getComponents().addSchemas("Base1", base1); + + Schema base2 = new StringSchema(); + base2.setDefault("base_2"); + openAPI.getComponents().addSchemas("Base2", base2); + + Schema schema = new Schema<>().allOf(List.of( + new Schema<>().$ref("#/components/schemas/Base1"), + new Schema<>().$ref("#/components/schemas/Base2") + )); + assertEquals(ModelUtils.resolveDefault(openAPI, schema), "base_2"); + } + + @Test + public void resolveDefault_allOf_lastNonNullDefaultWins() { + // allOf: [Base1(default="base_1"), NoDefault] — trailing null item does not clear candidate + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema base1 = new StringSchema(); + base1.setDefault("base_1"); + openAPI.getComponents().addSchemas("Base1", base1); + + Schema schema = new Schema<>().allOf(List.of( + new Schema<>().$ref("#/components/schemas/Base1"), + new StringSchema() // no default + )); + assertEquals(ModelUtils.resolveDefault(openAPI, schema), "base_1"); + } + + @Test + public void resolveDefault_fullChain_lastWriterWins() { + // Mirrors the documented resolution example: + // Base1: default="base_1" + // Base2: default="base_2" + // Intermediate: allOf: [Base1, Base2] → resolves to "base_2" (Base2 is last) + // Final: allOf: [Intermediate, {default:"final"}] → resolves to "final" (inline patch is last) + OpenAPI openAPI = TestUtils.createOpenAPI(); + + Schema base1 = new StringSchema(); + base1.setDefault("base_1"); + openAPI.getComponents().addSchemas("Base1", base1); + + Schema base2 = new StringSchema(); + base2.setDefault("base_2"); + openAPI.getComponents().addSchemas("Base2", base2); + + Schema intermediate = new Schema<>().allOf(List.of( + new Schema<>().$ref("#/components/schemas/Base1"), + new Schema<>().$ref("#/components/schemas/Base2") + )); + openAPI.getComponents().addSchemas("Intermediate", intermediate); + + Schema inlinePatch = new StringSchema(); + inlinePatch.setDefault("final"); + Schema finalSchema = new Schema<>().allOf(List.of( + new Schema<>().$ref("#/components/schemas/Intermediate"), + inlinePatch + )); + + // Intermediate alone resolves to "base_2" + assertEquals(ModelUtils.resolveDefault(openAPI, new Schema<>().$ref("#/components/schemas/Intermediate")), "base_2"); + // Full chain resolves to "final" + assertEquals(ModelUtils.resolveDefault(openAPI, finalSchema), "final"); + } } From 94ed6c5021efabd4218f1e818b29e717afdc4a3c Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 15 May 2026 12:39:11 +0200 Subject: [PATCH 26/43] fix: ensure correct parent reference ordering in allOf for default resolution --- .../codegen/OpenAPINormalizer.java | 8 ++- .../codegen/utils/ModelUtils.java | 4 ++ .../codegen/OpenAPINormalizerTest.java | 58 +++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java index 418b3c0ff2b5..9a6ee4e2542c 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java @@ -1757,8 +1757,14 @@ protected void ensureInheritanceForDiscriminatorMapping(Schema parent, Schema ch // already done, so no need to add return; } + // Prepend at position 0 so the parent ref is the FIRST item in the child's allOf. + // resolveDefault() uses last-writer-wins semantics, so anything the child already + // expressed in its allOf (including its own defaults) must come after the base in + // order to win. Appending at the end would make the parent the last — and therefore + // the winning — default, which is wrong: the normalizer is injecting structural + // inheritance here, not overriding child-specified defaults. Schema refToParent = new Schema<>().$ref(reference); - allOf.add(refToParent); + allOf.add(0, refToParent); } else { allOf = new ArrayList<>(); child.setAllOf(allOf); 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 2c40a8217c6a..ca2e235b52b6 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 @@ -1053,6 +1053,10 @@ public static ResolvedMinBound resolveMinimumBound(OpenAPI openAPI, Schema sc * @param openAPI the OpenAPI document used to resolve {@code $ref}s * @param schema the schema to inspect * @return the effective default value, or {@code null} if none is defined + * @implNote The result depends on the {@code allOf} array ordering as it exists when + * this method is called — i.e., after the OpenAPI normalizer has run. + * Normalizer mutations (e.g. {@code ensureInheritanceForDiscriminatorMapping} prepending + * a parent {@code $ref}) are therefore visible here and are accounted for by design. */ public static Object resolveDefault(OpenAPI openAPI, Schema schema) { schema = getReferencedSchema(openAPI, schema); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java index 2e25bb53b85c..171d98d415d0 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java @@ -1544,4 +1544,62 @@ public void oneOf_issue_23276() { assertNotNull(payload.getOneOf()); } + @Test + public void ensureInheritanceForDiscriminatorMapping_prependsParentRef_childDefaultWins() { + // When a child already has allOf items (e.g. a base schema carrying "base_default"), + // the normalizer must INSERT the parent $ref at position 0 — not append at the end. + // With last-writer-wins semantics in resolveDefault(), appending at the end would make + // the parent's default the winner, incorrectly overriding the child's own default. + OpenAPI openAPI = TestUtils.createOpenAPI(); + + Schema parent = new StringSchema(); + parent.setDefault("parent_default"); + + Schema base = new StringSchema(); + base.setDefault("base_default"); + openAPI.getComponents().addSchemas("Base", base); + + // Child: allOf → [Base]; no Parent ref yet — use mutable list (parser always produces ArrayList) + Schema child = new Schema<>().allOf(new ArrayList<>(List.of(new Schema<>().$ref("#/components/schemas/Base")))); + openAPI.getComponents().addSchemas("Child", child); + openAPI.getComponents().addSchemas("Parent", parent); + + OpenAPINormalizer normalizer = new OpenAPINormalizer(openAPI, Map.of()); + normalizer.ensureInheritanceForDiscriminatorMapping(parent, child, "Parent", new HashSet<>()); + + // Parent ref must be prepended at index 0; Base ref stays at index 1 + assertEquals(((Schema) child.getAllOf().get(0)).get$ref(), "#/components/schemas/Parent"); + assertEquals(((Schema) child.getAllOf().get(1)).get$ref(), "#/components/schemas/Base"); + + // resolveDefault walks top-to-bottom, last non-null wins → "base_default" + assertEquals(ModelUtils.resolveDefault(openAPI, child), "base_default"); + } + + @Test + public void ensureInheritanceForDiscriminatorMapping_noExistingAllOf_noProperties_directDefaultPreserved() { + // When a child has no allOf and no properties but does have a direct default, + // the normalizer creates allOf with just the parent $ref. The child's direct default + // must NOT be cleared — resolveDefault() short-circuits on it before inspecting allOf. + OpenAPI openAPI = TestUtils.createOpenAPI(); + + Schema parent = new StringSchema(); + parent.setDefault("parent_default"); + openAPI.getComponents().addSchemas("Parent", parent); + + // Child: direct default only, no allOf, no properties + Schema child = new StringSchema(); + child.setDefault("child_direct_default"); + openAPI.getComponents().addSchemas("Child", child); + + OpenAPINormalizer normalizer = new OpenAPINormalizer(openAPI, Map.of()); + normalizer.ensureInheritanceForDiscriminatorMapping(parent, child, "Parent", new HashSet<>()); + + // Child now has allOf (parent ref only), and still holds its direct default + assertNotNull(child.getAllOf()); + assertEquals(child.getDefault(), "child_direct_default"); + + // resolveDefault short-circuits on the direct default — child wins + assertEquals(ModelUtils.resolveDefault(openAPI, child), "child_direct_default"); + } + } From 07f6388a62e999f8dd21e46cc08b10743959670b Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Mon, 18 May 2026 10:20:00 +0200 Subject: [PATCH 27/43] DRY code --- .../languages/KotlinSpringServerCodegen.java | 47 ++++++----- .../languages/SpringPageableScanUtils.java | 78 +++++++++++-------- .../pom.xml | 5 ++ .../kotlin/org/openapitools/model/Category.kt | 4 + .../openapitools/model/ModelApiResponse.kt | 5 ++ .../kotlin/org/openapitools/model/Order.kt | 8 ++ .../main/kotlin/org/openapitools/model/Pet.kt | 6 ++ .../main/kotlin/org/openapitools/model/Tag.kt | 4 + .../kotlin/org/openapitools/model/User.kt | 10 +++ 9 files changed, 109 insertions(+), 58 deletions(-) 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 7c187418fa8d..0c818f7a1da9 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 @@ -1067,29 +1067,28 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation codegenOperation.operationId, library); } - // 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)) { + if (SPRING_BOOT.equals(library) + && Boolean.TRUE.equals(SpringPageableScanUtils.getXSpringPaginated(operation))) { // 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"); - } + 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"); - SpringPageableScanUtils.applySpringDocPageableAnnotation(codegenOperation, - SpringPageableScanUtils.AnnotationSyntax.KOTLIN, - DocumentationProvider.SPRINGDOC.equals(getDocumentationProvider())); - - // #8315 Remove matching Spring Data Web default query params if 'x-spring-paginated' with Pageable is used - // Build and attach pageable parameter annotations - SpringPageableScanUtils.removePageableQueryParams(codegenOperation); - pageableUtils.applyPageableAnnotations(codegenOperation, - generatePageableConstraintValidation, useBeanValidation, - generateSortValidation, SpringPageableScanUtils.AnnotationSyntax.KOTLIN); - } + codegenOperation.imports.add("Pageable"); + SpringPageableScanUtils.applySpringDocPageableAnnotation( + codegenOperation, + SpringPageableScanUtils.AnnotationSyntax.KOTLIN, + DocumentationProvider.SPRINGDOC.equals(getDocumentationProvider())); + + // #8315 Remove matching Spring Data Web default query params if 'x-spring-paginated' with Pageable is used + // Build and attach pageable parameter annotations + SpringPageableScanUtils.removePageableQueryParams(codegenOperation); + pageableUtils.applyPageableAnnotations( + codegenOperation, + generatePageableConstraintValidation, + useBeanValidation, + generateSortValidation, + SpringPageableScanUtils.AnnotationSyntax.KOTLIN); } // If substituteGenericPagedModel is enabled, replace paged-model return types @@ -1269,7 +1268,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"); @@ -1277,15 +1276,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"); } @@ -1458,7 +1457,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")); 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 8dd5fe888a20..44ac4d0f40a5 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 @@ -180,22 +180,36 @@ public void applyPageableAnnotations( * 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; - } - if (Boolean.TRUE.equals(paginated)) { - return true; - } + Boolean xSpringPaginated = getXSpringPaginated(operation); + if (xSpringPaginated != null) { + return xSpringPaginated; } - if (autoXSpringPaginated && operation.getParameters() != null) { - Set paramNames = operation.getParameters().stream() - .map(Parameter::getName) - .collect(Collectors.toSet()); - return paramNames.containsAll(DEFAULT_PAGEABLE_QUERY_PARAMS); + if (!autoXSpringPaginated || operation.getParameters() == null) { + return false; } - 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; } /** @@ -218,32 +232,28 @@ public static boolean willBePageable(Operation operation, boolean autoXSpringPag *
  • Otherwise → return {@code false}.
  • * * - * @param operation the raw OpenAPI {@link Operation} to inspect (and possibly mutate) - * @param autoXSpringPaginated whether auto-detection is enabled for this generator + * @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 (operation.getExtensions() != null) { - Object paginated = operation.getExtensions().get("x-spring-paginated"); - if (Boolean.FALSE.equals(paginated)) { - return false; - } - if (Boolean.TRUE.equals(paginated)) { - return true; - } + Boolean xSpringPaginated = getXSpringPaginated(operation); + if (xSpringPaginated != null) { + return xSpringPaginated; } - if (autoXSpringPaginated && operation.getParameters() != null) { - Set paramNames = operation.getParameters().stream() - .map(Parameter::getName) - .collect(Collectors.toSet()); - if (paramNames.containsAll(DEFAULT_PAGEABLE_QUERY_PARAMS)) { - if (operation.getExtensions() == null) { - operation.setExtensions(new HashMap<>()); - } - operation.getExtensions().put("x-spring-paginated", Boolean.TRUE); - return true; + if (!autoXSpringPaginated || operation.getParameters() == null) { + return false; + } + Set paramNames = operation.getParameters().stream() + .map(Parameter::getName) + .collect(Collectors.toSet()); + if (paramNames.containsAll(DEFAULT_PAGEABLE_QUERY_PARAMS)) { + if (operation.getExtensions() == null) { + operation.setExtensions(new HashMap<>()); } + operation.getExtensions().put("x-spring-paginated", Boolean.TRUE); + return true; } return false; } diff --git a/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/pom.xml b/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/pom.xml index 827ea7626a5d..ca8cd516716a 100644 --- a/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/pom.xml +++ b/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/pom.xml @@ -166,5 +166,10 @@ ${kotlin-test-junit5.version} test + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/Category.kt b/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/Category.kt index b7b5e059c976..07967fff45f0 100644 --- a/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/Category.kt +++ b/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/Category.kt @@ -2,6 +2,8 @@ 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 @@ -21,10 +23,12 @@ import io.swagger.v3.oas.annotations.media.Schema data class Category( @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("id") val id: kotlin.Long? = null, @get:Pattern(regexp="^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$") @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("name") val name: kotlin.String? = null ) : java.io.Serializable { diff --git a/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/ModelApiResponse.kt b/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/ModelApiResponse.kt index 394c0098f8f0..46a6dc2eae84 100644 --- a/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/ModelApiResponse.kt +++ b/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/ModelApiResponse.kt @@ -2,6 +2,8 @@ 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 @@ -22,12 +24,15 @@ import io.swagger.v3.oas.annotations.media.Schema data class ModelApiResponse( @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("code") val code: kotlin.Int? = null, @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("type") val type: kotlin.String? = null, @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("message") val message: kotlin.String? = null ) : java.io.Serializable { diff --git a/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/Order.kt b/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/Order.kt index 4749b654be89..715c0fee45d8 100644 --- a/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/Order.kt +++ b/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/Order.kt @@ -3,7 +3,9 @@ package org.openapitools.model import java.util.Objects import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter import com.fasterxml.jackson.annotation.JsonValue +import com.fasterxml.jackson.annotation.Nulls import jakarta.validation.constraints.DecimalMax import jakarta.validation.constraints.DecimalMin import jakarta.validation.constraints.Email @@ -27,21 +29,27 @@ import io.swagger.v3.oas.annotations.media.Schema data class Order( @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("id") val id: kotlin.Long? = null, @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("petId") val petId: kotlin.Long? = null, @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("quantity") val quantity: kotlin.Int? = null, @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("shipDate") val shipDate: java.time.OffsetDateTime? = null, @Schema(example = "null", description = "Order Status") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("status") val status: Order.Status? = null, @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("complete") val complete: kotlin.Boolean? = false ) : java.io.Serializable { diff --git a/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/Pet.kt b/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/Pet.kt index b5645e48a282..f0a7a8bb6017 100644 --- a/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/Pet.kt +++ b/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/Pet.kt @@ -3,7 +3,9 @@ package org.openapitools.model import java.util.Objects import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter import com.fasterxml.jackson.annotation.JsonValue +import com.fasterxml.jackson.annotation.Nulls import org.openapitools.model.Category import org.openapitools.model.Tag import jakarta.validation.constraints.DecimalMax @@ -35,18 +37,22 @@ data class Pet( @get:JsonProperty("photoUrls", required = true) val photoUrls: kotlin.collections.List, @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("id") val id: kotlin.Long? = null, @field:Valid @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("category") val category: Category? = null, @field:Valid @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("tags") val tags: kotlin.collections.List? = null, @Schema(example = "null", description = "pet status in the store") @Deprecated(message = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("status") val status: Pet.Status? = null ) : java.io.Serializable { diff --git a/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/Tag.kt b/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/Tag.kt index fbcd6bc6890a..321a45dc7b7f 100644 --- a/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/Tag.kt +++ b/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/Tag.kt @@ -2,6 +2,8 @@ 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 @@ -21,9 +23,11 @@ import io.swagger.v3.oas.annotations.media.Schema data class Tag( @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("id") val id: kotlin.Long? = null, @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("name") val name: kotlin.String? = null ) : java.io.Serializable { diff --git a/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/User.kt b/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/User.kt index 2ab37ba8ae92..0d1f073cf0a0 100644 --- a/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/User.kt +++ b/samples/server/petstore/kotlin-spring-declarative-interface-bean-validation/src/main/kotlin/org/openapitools/model/User.kt @@ -2,6 +2,8 @@ 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 @@ -27,27 +29,35 @@ import io.swagger.v3.oas.annotations.media.Schema data class User( @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("id") val id: kotlin.Long? = null, @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("username") val username: kotlin.String? = null, @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("firstName") val firstName: kotlin.String? = null, @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("lastName") val lastName: kotlin.String? = null, @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("email") val email: kotlin.String? = null, @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("password") val password: kotlin.String? = null, @Schema(example = "null", description = "") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("phone") val phone: kotlin.String? = null, @Schema(example = "null", description = "User Status") + @field:JsonSetter(nulls = Nulls.FAIL) @get:JsonProperty("userStatus") val userStatus: kotlin.Int? = null ) : java.io.Serializable { From 5bc80c65cd82ed0761f500341518a6ba7b84ae76 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Mon, 18 May 2026 10:28:14 +0200 Subject: [PATCH 28/43] DRY code --- .../languages/KotlinSpringServerCodegen.java | 8 ----- .../languages/SpringPageableScanUtils.java | 30 ++++++------------- 2 files changed, 9 insertions(+), 29 deletions(-) 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 0c818f7a1da9..d79bca91ae81 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 @@ -1250,14 +1250,6 @@ public void preprocessOpenAPI(OpenAPI openAPI) { // TODO: Handle tags } - /** - * Returns true if the given operation will have a Pageable parameter injected. - * Delegates to {@link SpringPageableScanUtils#willBePageable}. - */ - private boolean willBePageable(Operation operation) { - return SpringPageableScanUtils.willBePageable(operation, autoXSpringPaginated); - } - @Override public void postProcessModelProperty(CodegenModel model, CodegenProperty property) { super.postProcessModelProperty(model, property); 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 44ac4d0f40a5..13cb582c0f59 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 @@ -216,46 +216,34 @@ public static Boolean getXSpringPaginated(Operation operation) { * 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}.

    * - *

    Rules (in priority order):

    - *
      - *
    1. If {@code x-spring-paginated} is explicitly {@code false} → do nothing, return {@code false}.
    2. - *
    3. If {@code x-spring-paginated} is already {@code true} → return {@code true} without re-mutating.
    4. - *
    5. If {@code autoXSpringPaginated} is {@code true} and the operation has all three - * {@link #DEFAULT_PAGEABLE_QUERY_PARAMS} ({@code page}, {@code size}, {@code sort}) - * → set {@code x-spring-paginated: true} and return {@code true}.
    6. - *
    7. Otherwise → return {@code false}.
    8. - *
    - * * @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) { - Boolean xSpringPaginated = getXSpringPaginated(operation); - if (xSpringPaginated != null) { - return xSpringPaginated; - } - if (!autoXSpringPaginated || operation.getParameters() == null) { + if (!willBePageable(operation, autoXSpringPaginated)) { return false; } - Set paramNames = operation.getParameters().stream() - .map(Parameter::getName) - .collect(Collectors.toSet()); - if (paramNames.containsAll(DEFAULT_PAGEABLE_QUERY_PARAMS)) { + if (getXSpringPaginated(operation) == null) { if (operation.getExtensions() == null) { operation.setExtensions(new HashMap<>()); } operation.getExtensions().put("x-spring-paginated", Boolean.TRUE); - return true; } - return false; + return true; } /** From 3a6c41c83af0596f7c494a07f09b4e40262d87af Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 20 May 2026 14:31:38 +0200 Subject: [PATCH 29/43] revert default-resolution related changes as the behavior is unspecified and therefore dangerous --- .../codegen/OpenAPINormalizer.java | 8 +- .../languages/SpringPageableScanUtils.java | 7 +- .../codegen/utils/ModelUtils.java | 72 -------- .../codegen/OpenAPINormalizerTest.java | 58 ------- .../java/spring/SpringCodegenTest.java | 19 -- .../spring/KotlinSpringServerCodegenTest.java | 14 -- .../codegen/utils/ModelUtilsTest.java | 163 ------------------ .../kotlin/org/openapitools/api/PetApi.kt | 2 +- .../java/org/openapitools/api/PetApi.java | 2 +- .../openapitools/api/PetApiController.java | 21 --- .../api/PetApiValidationTest.java | 66 ------- 11 files changed, 8 insertions(+), 424 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java index cbc56e64a6c5..68750b1bdefc 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java @@ -1932,14 +1932,8 @@ protected void ensureInheritanceForDiscriminatorMapping(Schema parent, Schema ch // already done, so no need to add return; } - // Prepend at position 0 so the parent ref is the FIRST item in the child's allOf. - // resolveDefault() uses last-writer-wins semantics, so anything the child already - // expressed in its allOf (including its own defaults) must come after the base in - // order to win. Appending at the end would make the parent the last — and therefore - // the winning — default, which is wrong: the normalizer is injecting structural - // inheritance here, not overriding child-specified defaults. Schema refToParent = new Schema<>().$ref(reference); - allOf.add(0, refToParent); + allOf.add(refToParent); } else { allOf = new ArrayList<>(); child.setAllOf(allOf); 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 13cb582c0f59..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 @@ -535,10 +535,13 @@ public static Map scanPageableDefaults( if (schema == null) { continue; } - Object defaultValue = ModelUtils.resolveDefault(openAPI, schema); - if (defaultValue == null) { + if (schema.get$ref() != null) { + schema = ModelUtils.getReferencedSchema(openAPI, schema); + } + if (schema == null || schema.getDefault() == null) { continue; } + Object defaultValue = schema.getDefault(); switch (param.getName()) { case PAGE: if (defaultValue instanceof Number) { 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 ca2e235b52b6..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 @@ -1020,78 +1020,6 @@ public static ResolvedMinBound resolveMinimumBound(OpenAPI openAPI, Schema sc .reduce(result, ResolvedMinBound::getLargerMinBound); } - /** - * Returns the effective {@code default} for the given schema using - * last-writer-wins semantics across the flattened {@code allOf} chain. - * - *

    Resolution algorithm

    - *
      - *
    1. Resolve any top-level {@code $ref} to obtain the concrete schema.
    2. - *
    3. If the schema has a direct {@code default} (i.e. the {@code default:} key at - * the same level as {@code allOf:}), return it immediately — no traversal needed.
    4. - *
    5. Otherwise walk the {@code allOf} array top-to-bottom. Each - * item is itself fully resolved (recursing into nested {@code allOf} chains), and - * its result — if non-null — overwrites the current candidate (last-writer-wins).
    6. - *
    - * - *

    Example

    - *
    {@code
    -     * # Base1: default = "base_1"   → Step 1: candidate = "base_1"
    -     * # Base2: default = "base_2"   → Step 2: candidate = "base_2"  (overwrites Step 1)
    -     * #
    -     * # Intermediate:
    -     * #   allOf: [$ref Base1, $ref Base2]
    -     * #   → resolves to "base_2"  (Base2 is last, wins over Base1)
    -     * #
    -     * # Final:
    -     * #   allOf:
    -     * #     - $ref: Intermediate    → Step 3a: candidate = "base_2"
    -     * #     - default: "final"      → Step 3b: candidate = "final"  (overwrites Step 3a)
    -     * #   → resolves to "final"
    -     * }
    - * - * @param openAPI the OpenAPI document used to resolve {@code $ref}s - * @param schema the schema to inspect - * @return the effective default value, or {@code null} if none is defined - * @implNote The result depends on the {@code allOf} array ordering as it exists when - * this method is called — i.e., after the OpenAPI normalizer has run. - * Normalizer mutations (e.g. {@code ensureInheritanceForDiscriminatorMapping} prepending - * a parent {@code $ref}) are therefore visible here and are accounted for by design. - */ - public static Object resolveDefault(OpenAPI openAPI, Schema schema) { - schema = getReferencedSchema(openAPI, schema); - if (schema == null) return null; - - // Direct default short-circuits — no allOf traversal needed. - Object directDefault = schema.getDefault(); - if (directDefault != null) { - return directDefault; - } - - // Walk allOf top-to-bottom; each non-null result overwrites the previous candidate. - if (hasAllOf(schema)) { - return getLastNonNullDefault(openAPI, schema); - } - return null; - } - - /** - * Walks {@code allOf} items top-to-bottom and returns the last non-null - * {@code default} value found (last-writer-wins). Each item is fully resolved - * (including its own nested {@code allOf}) before the candidate is updated, so - * arbitrarily deep chains are flattened correctly. - */ - private static Object getLastNonNullDefault(OpenAPI openAPI, Schema schema) { - Object last = null; - for (Schema item : schema.getAllOf()) { - Object resolved = resolveDefault(openAPI, item); - if (resolved != null) { - last = resolved; - } - } - return last; - } - public static boolean hasValidation(Schema sc) { return ( sc.getMaxItems() != null || diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java index 9d6c9a926e15..87e96066c0bc 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java @@ -1844,62 +1844,4 @@ public void oneOf_issue_23276() { assertNotNull(payload.getOneOf()); } - @Test - public void ensureInheritanceForDiscriminatorMapping_prependsParentRef_childDefaultWins() { - // When a child already has allOf items (e.g. a base schema carrying "base_default"), - // the normalizer must INSERT the parent $ref at position 0 — not append at the end. - // With last-writer-wins semantics in resolveDefault(), appending at the end would make - // the parent's default the winner, incorrectly overriding the child's own default. - OpenAPI openAPI = TestUtils.createOpenAPI(); - - Schema parent = new StringSchema(); - parent.setDefault("parent_default"); - - Schema base = new StringSchema(); - base.setDefault("base_default"); - openAPI.getComponents().addSchemas("Base", base); - - // Child: allOf → [Base]; no Parent ref yet — use mutable list (parser always produces ArrayList) - Schema child = new Schema<>().allOf(new ArrayList<>(List.of(new Schema<>().$ref("#/components/schemas/Base")))); - openAPI.getComponents().addSchemas("Child", child); - openAPI.getComponents().addSchemas("Parent", parent); - - OpenAPINormalizer normalizer = new OpenAPINormalizer(openAPI, Map.of()); - normalizer.ensureInheritanceForDiscriminatorMapping(parent, child, "Parent", new HashSet<>()); - - // Parent ref must be prepended at index 0; Base ref stays at index 1 - assertEquals(((Schema) child.getAllOf().get(0)).get$ref(), "#/components/schemas/Parent"); - assertEquals(((Schema) child.getAllOf().get(1)).get$ref(), "#/components/schemas/Base"); - - // resolveDefault walks top-to-bottom, last non-null wins → "base_default" - assertEquals(ModelUtils.resolveDefault(openAPI, child), "base_default"); - } - - @Test - public void ensureInheritanceForDiscriminatorMapping_noExistingAllOf_noProperties_directDefaultPreserved() { - // When a child has no allOf and no properties but does have a direct default, - // the normalizer creates allOf with just the parent $ref. The child's direct default - // must NOT be cleared — resolveDefault() short-circuits on it before inspecting allOf. - OpenAPI openAPI = TestUtils.createOpenAPI(); - - Schema parent = new StringSchema(); - parent.setDefault("parent_default"); - openAPI.getComponents().addSchemas("Parent", parent); - - // Child: direct default only, no allOf, no properties - Schema child = new StringSchema(); - child.setDefault("child_direct_default"); - openAPI.getComponents().addSchemas("Child", child); - - OpenAPINormalizer normalizer = new OpenAPINormalizer(openAPI, Map.of()); - normalizer.ensureInheritanceForDiscriminatorMapping(parent, child, "Parent", new HashSet<>()); - - // Child now has allOf (parent ref only), and still holds its direct default - assertNotNull(child.getAllOf()); - assertEquals(child.getDefault(), "child_direct_default"); - - // resolveDefault short-circuits on the direct default — child wins - assertEquals(ModelUtils.resolveDefault(openAPI, child), "child_direct_default"); - } - } 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 a049e2e14812..ed998214a112 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 @@ -7366,25 +7366,6 @@ public void generatePageableConstraintValidationResolvesMinimumFromAllOfRef() th .containsWithNameAndAttributes("ValidPageable", Map.of("minSize", "5")); } - @Test - public void scanPageableDefaultsResolvesDefaultFromAllOfRef() 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"); - - Map files = generateFromContract( - "src/test/resources/3_0/spring/petstore-sort-validation.yaml", SPRING_BOOT, props); - - // findPetsWithDefaultFromAllOfRef: default: 7 is on the referenced schema only - JavaFileAssert.assertThat(files.get("PetApi.java")) - .assertMethod("findPetsWithDefaultFromAllOfRef") - .assertParameter("pageable") - .assertParameterAnnotations() - .containsWithNameAndAttributes("PageableDefault", Map.of("size", "7")); - } - // ------------------------------------------------------------------------- // @PageableDefault / @SortDefault tests // ------------------------------------------------------------------------- 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 d5cceb963e74..4ac2f195bb74 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 @@ -4312,20 +4312,6 @@ public void generatePageableConstraintValidationResolvesMinimumFromAllOfRef() th "@ValidPageable(minSize = 5) should be resolved from allOf $ref schema"); } - @Test - public void scanPageableDefaultsResolvesDefaultFromAllOfRef() throws Exception { - Map additionalProperties = new HashMap<>(); - additionalProperties.put(USE_TAGS, "true"); - additionalProperties.put(INTERFACE_ONLY, "true"); - additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); - - Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); - - File petApi = files.get("PetApi.kt"); - // findPetsWithDefaultFromAllOfRef: default: 7 is on the referenced schema only - assertFileContains(petApi.toPath(), "@PageableDefault(size = 7)"); - } - // ========== AUTO X-SPRING-PAGINATED TESTS ========== @Test 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 c9259421bb8a..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 @@ -1181,167 +1181,4 @@ public void resolveMinimumBound_nestedAllOf_mostRestrictiveAcrossAllLevels() { assertEquals(bound.minBound, BigDecimal.valueOf(20)); } - // ------------------------------------------------------------------------- - // resolveDefault - // ------------------------------------------------------------------------- - - @Test - public void resolveDefault_nullSchema_returnsNull() { - assertNull(ModelUtils.resolveDefault(new OpenAPI(), null)); - } - - @Test - public void resolveDefault_noDefaultDefined_returnsNull() { - OpenAPI openAPI = TestUtils.createOpenAPI(); - assertNull(ModelUtils.resolveDefault(openAPI, new IntegerSchema())); - } - - @Test - public void resolveDefault_inlineDefault_returnsIt() { - OpenAPI openAPI = TestUtils.createOpenAPI(); - Schema schema = new IntegerSchema(); - schema.setDefault(10); - assertEquals(ModelUtils.resolveDefault(openAPI, schema), 10); - } - - @Test - public void resolveDefault_refToSchemaWithDefault_resolvesRef() { - OpenAPI openAPI = TestUtils.createOpenAPI(); - Schema refTarget = new IntegerSchema(); - refTarget.setDefault(0); - openAPI.getComponents().addSchemas("MyInt", refTarget); - - Schema ref = new Schema<>().$ref("#/components/schemas/MyInt"); - assertEquals(ModelUtils.resolveDefault(openAPI, ref), 0); - } - - @Test - public void resolveDefault_allOfItemHasDefault_returnsIt() { - OpenAPI openAPI = TestUtils.createOpenAPI(); - Schema allOfItem = new IntegerSchema(); - allOfItem.setDefault(20); - openAPI.getComponents().addSchemas("Base", allOfItem); - - // Inline schema has no default; allOf item has default=20 - Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); - assertEquals(ModelUtils.resolveDefault(openAPI, schema), 20); - } - - @Test - public void resolveDefault_inlineDefaultTakesPrecedenceOverAllOf() { - OpenAPI openAPI = TestUtils.createOpenAPI(); - Schema allOfItem = new IntegerSchema(); - allOfItem.setDefault(99); - openAPI.getComponents().addSchemas("Base", allOfItem); - - // Inline schema default=5 should win over allOf item default=99 - Schema schema = new IntegerSchema(); - schema.setDefault(5); - schema.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); - assertEquals(ModelUtils.resolveDefault(openAPI, schema), 5); - } - - @Test - public void resolveDefault_allOfItemsNoDefault_returnsNull() { - OpenAPI openAPI = TestUtils.createOpenAPI(); - openAPI.getComponents().addSchemas("Base", new IntegerSchema()); // no default - - Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); - assertNull(ModelUtils.resolveDefault(openAPI, schema)); - } - - @Test - public void resolveDefault_stringDefault_returnsIt() { - OpenAPI openAPI = TestUtils.createOpenAPI(); - Schema schema = new StringSchema(); - schema.setDefault("hello"); - assertEquals(ModelUtils.resolveDefault(openAPI, schema), "hello"); - } - - @Test - public void resolveDefault_nestedAllOf_findsDefaultInNestedItem() { - OpenAPI openAPI = TestUtils.createOpenAPI(); - // base has default=99; mid allOf → base; top allOf → mid - Schema base = new IntegerSchema(); - base.setDefault(99); - openAPI.getComponents().addSchemas("Base", base); - - Schema mid = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); - openAPI.getComponents().addSchemas("Mid", mid); - - Schema top = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Mid"))); - - assertEquals(ModelUtils.resolveDefault(openAPI, top), 99); - } - - @Test - public void resolveDefault_allOf_lastDefaultWins() { - // allOf: [Base1(default="base_1"), Base2(default="base_2")] - // Base2 is last → wins - OpenAPI openAPI = TestUtils.createOpenAPI(); - Schema base1 = new StringSchema(); - base1.setDefault("base_1"); - openAPI.getComponents().addSchemas("Base1", base1); - - Schema base2 = new StringSchema(); - base2.setDefault("base_2"); - openAPI.getComponents().addSchemas("Base2", base2); - - Schema schema = new Schema<>().allOf(List.of( - new Schema<>().$ref("#/components/schemas/Base1"), - new Schema<>().$ref("#/components/schemas/Base2") - )); - assertEquals(ModelUtils.resolveDefault(openAPI, schema), "base_2"); - } - - @Test - public void resolveDefault_allOf_lastNonNullDefaultWins() { - // allOf: [Base1(default="base_1"), NoDefault] — trailing null item does not clear candidate - OpenAPI openAPI = TestUtils.createOpenAPI(); - Schema base1 = new StringSchema(); - base1.setDefault("base_1"); - openAPI.getComponents().addSchemas("Base1", base1); - - Schema schema = new Schema<>().allOf(List.of( - new Schema<>().$ref("#/components/schemas/Base1"), - new StringSchema() // no default - )); - assertEquals(ModelUtils.resolveDefault(openAPI, schema), "base_1"); - } - - @Test - public void resolveDefault_fullChain_lastWriterWins() { - // Mirrors the documented resolution example: - // Base1: default="base_1" - // Base2: default="base_2" - // Intermediate: allOf: [Base1, Base2] → resolves to "base_2" (Base2 is last) - // Final: allOf: [Intermediate, {default:"final"}] → resolves to "final" (inline patch is last) - OpenAPI openAPI = TestUtils.createOpenAPI(); - - Schema base1 = new StringSchema(); - base1.setDefault("base_1"); - openAPI.getComponents().addSchemas("Base1", base1); - - Schema base2 = new StringSchema(); - base2.setDefault("base_2"); - openAPI.getComponents().addSchemas("Base2", base2); - - Schema intermediate = new Schema<>().allOf(List.of( - new Schema<>().$ref("#/components/schemas/Base1"), - new Schema<>().$ref("#/components/schemas/Base2") - )); - openAPI.getComponents().addSchemas("Intermediate", intermediate); - - Schema inlinePatch = new StringSchema(); - inlinePatch.setDefault("final"); - Schema finalSchema = new Schema<>().allOf(List.of( - new Schema<>().$ref("#/components/schemas/Intermediate"), - inlinePatch - )); - - // Intermediate alone resolves to "base_2" - assertEquals(ModelUtils.resolveDefault(openAPI, new Schema<>().$ref("#/components/schemas/Intermediate")), "base_2"); - // Full chain resolves to "final" - assertEquals(ModelUtils.resolveDefault(openAPI, finalSchema), "final"); - } } 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 98e5aa03d13e..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 @@ -110,7 +110,7 @@ interface PetApi { value = [PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF], produces = ["application/json"] ) - fun findPetsWithDefaultFromAllOfRef(@PageableDefault(size = 7) pageable: Pageable): ResponseEntity> { + fun findPetsWithDefaultFromAllOfRef(pageable: Pageable): ResponseEntity> { return ResponseEntity(HttpStatus.NOT_IMPLEMENTED) } 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 57841e79adac..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 @@ -132,7 +132,7 @@ ResponseEntity> findPetsWithArraySortRefEnum( produces = { "application/json" } ) ResponseEntity> findPetsWithDefaultFromAllOfRef( - @PageableDefault(size = 7) final Pageable pageable + final Pageable pageable ); 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 12b29649db49..bef1f3a47ab0 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 @@ -90,16 +90,6 @@ public ResponseEntity> findPetsWithPageAndSizeConstraint(Pageable page return ResponseEntity.ok(Collections.emptyList()); } - @Override - public ResponseEntity> findPetsWithMinSizeConstraintFromAllOfRef(Pageable pageable) { - return ResponseEntity.ok(Collections.emptyList()); - } - - @Override - public ResponseEntity> findPetsWithSizeConstraintFromAllOfRef(Pageable pageable) { - return ResponseEntity.ok(Collections.emptyList()); - } - // ── @PageableDefault ───────────────────────────────────────────────────── // @PageableDefault(page = 0, size = 25) @@ -116,17 +106,6 @@ public ResponseEntity> findPetsWithPageSizeDefaultsOnly(Pageable pagea return ResponseEntity.ok(Collections.emptyList()); } - // @PageableDefault(size = 7) - - @Override - public ResponseEntity> findPetsWithDefaultFromAllOfRef(Pageable pageable) { - if (pageable.getPageSize() != 7) { - throw new IllegalStateException( - "@PageableDefault size: expected 7, got " + pageable.getPageSize()); - } - return ResponseEntity.ok(Collections.emptyList()); - } - // ── @SortDefault ───────────────────────────────────────────────────────── // @SortDefault(sort = {"name"}, direction = DESC) 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 4cb3fda52b0d..7e3bceea5072 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 @@ -178,70 +178,4 @@ void pageableDefaultAndSortDefaults_absentParamsResolveAllDefaults() throws Exce mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_ALL_DEFAULTS)) .andExpect(status().isOk()); } - - // ── @PageableDefault — size default from allOf $ref ─────────────────────── - // Endpoint: GET /pet/findWithDefaultFromAllOfRef @PageableDefault(size = 7) - // PetApiController asserts size == 7; returns 200 on success, throws on mismatch. - - @Test - void pageableDefault_absentSizeParamResolvesToSizeSevenDefault() throws Exception { - mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF)) - .andExpect(status().isOk()); - } - - // ── @ValidPageable — minSize constraint from allOf $ref ─────────────────── - // Endpoint: GET /pet/findWithMinSizeConstraintFromAllOfRef minSize = 5 - - @Test - void validPageable_sizeAboveMinimumReturns200() throws Exception { - mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF) - .param("size", "6")) - .andExpect(status().isOk()); - } - - @Test - void validPageable_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_sizeBelowMinimumReturns400() throws Exception { - mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF) - .param("size", "4")) - .andExpect(status().isBadRequest()); - } - - @Test - void validPageable_unpagedPageableIsAllowedForMinConstraint() throws Exception { - // Unpaged Pageable (no params, no @PageableDefault) bypasses the validator per - // PageableConstraintValidator#isValid which returns true immediately for !isPaged(). - mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF)) - .andExpect(status().isOk()); - } - - // ── @ValidPageable — maxSize constraint from allOf $ref ─────────────────── - // Endpoint: GET /pet/findWithSizeConstraintFromAllOfRef maxSize = 75 - - @Test - void validPageable_sizeBelowMaximumReturns200_forSizeConstraintFromAllOfRef() 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_sizeAtMaximumReturns200_forSizeConstraintFromAllOfRef() 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_sizeExceedsMaximumReturns400_forSizeConstraintFromAllOfRef() throws Exception { - mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF) - .param("size", "76")) - .andExpect(status().isBadRequest()); - } } From 1f6e6ed92705db95a5bd9c8dda332ee4e02a9801 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 20 May 2026 14:46:37 +0200 Subject: [PATCH 30/43] add missing PetApiController implementations and validation tests for allOf-resolved constraints --- .../openapitools/api/PetApiController.java | 15 ++++++ .../api/PetApiValidationTest.java | 48 +++++++++++++++++++ 2 files changed, 63 insertions(+) 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/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 From a958ee6b9acc84edc3323e71daa9b96fa6c2a830 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Sat, 30 May 2026 22:51:37 +0200 Subject: [PATCH 31/43] fix(spring): re-key GenericSubstitutionSupport instanceRegistry after toModelName() Mirrors the modelNameSuffix/Prefix fix done for PagedModelScanUtils in bugfix/pageable-scan-resolve-allof-constraints. Before this fix, instanceRegistry was keyed by raw spec schema names (e.g. 'UserResponse'), while op.returnBaseType and objs keys are already transformed via toModelName() (e.g. 'UserResponseDto'). This caused: 1. substituteReturnType: registry lookup missed -> no substitution 2. suppressGenericSchemas: objs.remove used wrong (transformed) key 3. substituteReturnType: import removal used raw name, not transformed Fix: - preprocessOpenAPI: re-key instanceRegistry with ctx.toModelName() after all three tiers have populated it - substituteReturnType: use ctx.toModelName(inst.schemaName) for import removal, since op.imports holds transformed names - suppressGenericSchemas: use inst.schemaName (raw, preserved in GenericInstance) for objs.remove(), not the now-transformed map key Tests added for SpringCodegen and KotlinSpringServerCodegen verifying that genericPatterns substitution and schema suppression work correctly when modelNameSuffix or modelNamePrefix is configured. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../languages/GenericSubstitutionSupport.java | 23 +++++- .../java/spring/SpringCodegenTest.java | 49 ++++++++++++ .../spring/KotlinSpringServerCodegenTest.java | 77 +++++++++++++++++++ 3 files changed, 145 insertions(+), 4 deletions(-) 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 index 22dd09592c02..e3272364a64c 100644 --- 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 @@ -211,6 +211,18 @@ public void preprocessOpenAPI(OpenAPI openAPI, Context ctx) { 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()); + reKeyed.put(transformedKey, entry.getValue()); + } + instanceRegistry.clear(); + instanceRegistry.putAll(reKeyed); + LOGGER.info("GenericSubstitutionSupport: detected {} generic schema instance(s): {}", instanceRegistry.size(), instanceRegistry.keySet()); @@ -293,7 +305,9 @@ public void substituteReturnType(CodegenOperation op, Context ctx) { op.imports.add(ctx.toModelName(resolvedSchema)); } if (ctx.getAnnotationLibrary() == AnnotationLibrary.NONE) { - op.imports.remove(inst.schemaName); + // Remove the wrapper schema import using its transformed name (matching op.imports + // entries, which are already toModelName()-processed by super.fromOperation). + op.imports.remove(ctx.toModelName(inst.schemaName)); } LOGGER.info("GenericSubstitutionSupport: operation '{}': replacing return type '{}' with '{}'", @@ -329,11 +343,12 @@ public Map suppressGenericSchemas(Map objs for (Map.Entry entry : instanceRegistry.entrySet()) { - String schemaName = entry.getKey(); GenericSchemaScanUtils.GenericInstance inst = entry.getValue(); - if (objs.remove(schemaName) != null) { + // 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) { LOGGER.info("GenericSubstitutionSupport: suppressing model '{}' → {}", - schemaName, inst.genericClassName + "<" + inst.typeArgs.values() + ">"); + inst.schemaName, inst.genericClassName + "<" + inst.typeArgs.values() + ">"); } } return objs; 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 cb7c1fb288f7..f9deec7dddc0 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 @@ -8032,6 +8032,55 @@ public void genericPatterns_singleParam_resultClassStillHasOneTypeParam() throws .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"); + } + // ------------------------------------------------------------------------- // substituteGenericPagedModel — spring-cloud // ------------------------------------------------------------------------- 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 4ac2f195bb74..8816aef170d1 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 @@ -6029,6 +6029,83 @@ public void substituteGenericPagedModel_withModelNameSuffix_suppressesPagedSchem 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"); + } + @Test(description = "oneOf with discriminator generates thin sealed interface with Jackson annotations") public void testOneOfWithDiscriminatorGeneratesThinInterface() throws IOException { From 3ca0de0538970717214af2198015d86db4de6a10 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Sat, 30 May 2026 23:54:14 +0200 Subject: [PATCH 32/43] fix: property-level generic substitution + recursive type-arg expansion - Substitute generic-instance schema refs in model properties (not just operation return types) so that schemas like OrderDetails.userResult rendered as ApiResponse when UserResponse is a detected instance. - For array properties, use prop.complexType as fallback lookup key (prop.baseType = 'List' for arrays; item type is in complexType). Also update prop.items.dataType / datatypeWithEnum so that addXxxItem helper methods in pojo.mustache show the correct substituted type. - Iterate all property lists with IdentityHashMap deduplication: CodegenModel.removeAllDuplicatedProperty() clones vars, requiredVars, and optionalVars into independent instances. Kotlin Spring templates render from requiredVars/optionalVars, not vars, so all lists must be updated. - Add toModelImport(String) to Context interface (Spring + Kotlin impls) and sync ModelsMap.imports (pre-built List before postProcessAllModels) with removals and additions so import statements in generated files are correct even with modelNameSuffix/Prefix. - Fix Scenario E suppression-safety tests: allOf without discriminator uses composition (model.parent = null), so the generic instance CAN be safely suppressed. Tests updated to assert UserResponse is absent. - Fix Kotlin Scenario C test: kotlinGenericPatternsProps() only included the Response pattern; added Page pattern inline for the recursive test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../languages/GenericSubstitutionSupport.java | 332 ++++++++++++++++-- .../languages/KotlinSpringServerCodegen.java | 1 + .../codegen/languages/SpringCodegen.java | 1 + .../java/spring/SpringCodegenTest.java | 109 ++++++ .../spring/KotlinSpringServerCodegenTest.java | 68 ++++ .../spring/petstore-generics-inheritance.yaml | 87 +++++ .../3_0/spring/petstore-generics.yaml | 144 ++++++++ 7 files changed, 722 insertions(+), 20 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics-inheritance.yaml 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 index e3272364a64c..f67ade7b41da 100644 --- 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 @@ -17,7 +17,9 @@ 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; @@ -123,6 +125,14 @@ public interface Context { * {@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); } // ========================================================================= @@ -272,7 +282,10 @@ public void preprocessOpenAPI(OpenAPI openAPI, Context ctx) { * 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}.

    + *

    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(…)}.

    @@ -287,27 +300,19 @@ public void substituteReturnType(CodegenOperation op, Context ctx) { } String oldType = op.returnType; - - // Build type args string from all slots in order - StringBuilder typeArgsBuilder = new StringBuilder(); - for (String slotProp : inst.slotTypeParams.keySet()) { - if (typeArgsBuilder.length() > 0) typeArgsBuilder.append(", "); - typeArgsBuilder.append(ctx.toModelName(inst.typeArgs.get(slotProp))); - } - String newType = inst.genericClassName + "<" + typeArgsBuilder + ">"; + String newType = buildGenericTypeName(inst, ctx, new HashSet<>()); op.returnType = newType; op.returnBaseType = inst.genericClassName; op.returnContainer = null; // generic wrapper is not a container - op.imports.add(inst.genericClassName); - for (String resolvedSchema : inst.typeArgs.values()) { - op.imports.add(ctx.toModelName(resolvedSchema)); - } + collectImportsToAdd(inst, ctx, op.imports, new HashSet<>()); if (ctx.getAnnotationLibrary() == AnnotationLibrary.NONE) { - // Remove the wrapper schema import using its transformed name (matching op.imports - // entries, which are already toModelName()-processed by super.fromOperation). - op.imports.remove(ctx.toModelName(inst.schemaName)); + // 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 '{}'", @@ -319,12 +324,22 @@ public void substituteReturnType(CodegenOperation op, Context ctx) { // ========================================================================= /** - * Removes concrete generic-instance schemas (e.g. {@code UserResponse}, - * {@code PetResponse}) from the model map when {@code annotationLibrary=none}. + * 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. Only when {@code annotationLibrary=none} is it safe to suppress them.

    + * 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 @@ -341,14 +356,28 @@ public Map suppressGenericSchemas(Map objs return objs; } + substitutePropertyTypes(objs, ctx); + 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) { LOGGER.info("GenericSubstitutionSupport: suppressing model '{}' → {}", - inst.schemaName, inst.genericClassName + "<" + inst.typeArgs.values() + ">"); + inst.schemaName, buildGenericTypeName(inst, ctx, new HashSet<>())); } } return objs; @@ -470,6 +499,269 @@ private static String capitalize(String 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. + 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} that was not + * substituted or via {@code model.parent} (allOf inheritance). + * + *

    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; + } + for (CodegenProperty prop : model.vars) { + if (transformedKey.equals(prop.baseType) + || transformedKey.equals(prop.complexType)) { + return true; + } + } + } + } + return false; + } + /** * Returns the number of detected generic instances (for testing). */ 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 c87117089daf..6f0e7e9efbe4 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 @@ -204,6 +204,7 @@ public String getDescription() { // GenericSubstitutionSupport.Context implementation @Override public String fileExtension() { return "kt"; } + @Override public String toModelImport(String className) { return modelPackage() + "." + className; } @Setter private boolean companionObject = false; @Setter private boolean useEnumValueInterface = false; 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 4b612f65cd2e..51a2f3e92dea 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 @@ -222,6 +222,7 @@ public enum RequestMappingMode { // GenericSubstitutionSupport.Context implementation @Override public String fileExtension() { return "java"; } + @Override public String toModelImport(String className) { return modelPackage() + "." + className; } // Holds scan results for Spring Pageable features (populated during preprocessOpenAPI) private final SpringPageableScanUtils pageableUtils = new SpringPageableScanUtils(); 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 f9deec7dddc0..b87a19422cea 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 @@ -8081,6 +8081,115 @@ public void genericPatterns_withModelNameSuffix_suppressesConcreteSchemaClasses( 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"); + } + // ------------------------------------------------------------------------- // substituteGenericPagedModel — spring-cloud // ------------------------------------------------------------------------- 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 8816aef170d1..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 @@ -6106,6 +6106,74 @@ public void genericPatterns_withModelNameSuffix_suppressesConcreteSchemaClasses( 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 { 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.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-generics.yaml index a98a0a9a024f..7294e36d8bdf 100644 --- 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 @@ -188,6 +188,70 @@ paths: 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: @@ -506,3 +570,83 @@ components: 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 From 3b901769150be3e8f455623c1a927b4b73104932 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Sun, 31 May 2026 00:44:38 +0200 Subject: [PATCH 33/43] feat: respect schemaMapping in genericPatterns + warn on Mode B importMapping collision Gap A: when a Mode B genericClass collides with a pre-existing importMapping entry, log a warning before putIfAbsent so users are aware of the file-vs-import mismatch. Gap B: add schemaMapping() to the Context interface; implement in SpringCodegen and KotlinSpringServerCodegen. In preprocessOpenAPI, after re-keying the instance registry, remove any instance whose raw schemaName is present in schemaMapping() and log a warning. This prevents genericPatterns from overriding an explicit schemaMapping override. Test: genericPatterns_schemaMappingOnInstance_skipsSubstitution verifies that a schema-mapped UserResponse is not substituted to ApiResponse while other instances (PetResponse) are still substituted normally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../languages/GenericSubstitutionSupport.java | 38 +++++++++++++++++++ .../languages/KotlinSpringServerCodegen.java | 1 + .../codegen/languages/SpringCodegen.java | 1 + .../java/spring/SpringCodegenTest.java | 24 ++++++++++++ 4 files changed, 64 insertions(+) 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 index f67ade7b41da..5de4a82df7be 100644 --- 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 @@ -133,6 +133,13 @@ public interface Context { * 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(); } // ========================================================================= @@ -233,6 +240,24 @@ public void preprocessOpenAPI(OpenAPI openAPI, Context ctx) { 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()); @@ -256,6 +281,19 @@ public void preprocessOpenAPI(OpenAPI openAPI, Context ctx) { 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)) { 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 6f0e7e9efbe4..8dbd6893e7c1 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 @@ -205,6 +205,7 @@ public String getDescription() { // GenericSubstitutionSupport.Context implementation @Override public String fileExtension() { return "kt"; } @Override public String toModelImport(String className) { return modelPackage() + "." + className; } + @Override public Map schemaMapping() { return schemaMapping; } @Setter private boolean companionObject = false; @Setter private boolean useEnumValueInterface = false; 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 51a2f3e92dea..cf8a641102ee 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 @@ -223,6 +223,7 @@ public enum RequestMappingMode { // GenericSubstitutionSupport.Context implementation @Override public String fileExtension() { return "java"; } @Override public String toModelImport(String className) { return modelPackage() + "." + className; } + @Override public Map schemaMapping() { return schemaMapping; } // Holds scan results for Spring Pageable features (populated during preprocessOpenAPI) private final SpringPageableScanUtils pageableUtils = new SpringPageableScanUtils(); 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 b87a19422cea..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 @@ -8190,6 +8190,30 @@ public void genericPatterns_suppressionSafety_keepsSchemaWhenExtended() throws I 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 // ------------------------------------------------------------------------- From 6d20f38bd386d3e68e434ae7bf8bfeb2b3ce34fe Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Sun, 31 May 2026 01:29:37 +0200 Subject: [PATCH 34/43] feat: unify substituteGenericPagedModel under GenericSubstitutionSupport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add addPreScannedInstance() to GenericSubstitutionSupport accepting a GenericInstance and an optional rawMetaSchemaName for companion schema suppression. Meta-schemas are suppressed only when their main schema was actually removed in the same suppressGenericSchemas() pass. - Add contributeToGenericSubstitution() to SpringPageableSupport: converts each structurally-detected DetectedPagedModel into a GenericInstance and registers it via addPreScannedInstance(), giving both features a single unified substitution/suppression path. - Remove pageableSupport.substituteReturnType() from fromOperation() in both SpringCodegen and KotlinSpringServerCodegen — handled by the unified GenericSubstitutionSupport.substituteReturnType(). - Remove pageableSupport.suppressPagedModels() from postProcessAllModels() in both generators — handled by suppressGenericSchemas() which now also processes extraSuppressedMetaSchemas. - Fix toModelImport() in both Context implementations (SpringCodegen, KotlinSpringServerCodegen) to return already-FQN names as-is, matching AbstractKotlinCodegen.needToImport behaviour. Prevents schema-mapped types (e.g. schemaMapping: User → com.example.external.ExternalUser) from getting a spurious modelPackage prefix in operation imports. Gains from unification: - Property-level substitution now applies to paged-model schemas - isStillReferenced safety check protects paged-model suppression - modelNameSuffix/Prefix now work correctly with substituteGenericPagedModel - Single import-management flow via syncModelsMapImports All 86 genericPatterns* + substituteGenericPagedModel* tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../languages/GenericSubstitutionSupport.java | 68 +++++++++++++++++- .../languages/KotlinSpringServerCodegen.java | 18 +++-- .../codegen/languages/SpringCodegen.java | 16 ++--- .../languages/SpringPageableSupport.java | 70 +++++++++++++++++-- 4 files changed, 146 insertions(+), 26 deletions(-) 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 index 5de4a82df7be..2c40e29e6317 100644 --- 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 @@ -157,6 +157,15 @@ public interface Context { private final Map instanceRegistry = new LinkedHashMap<>(); + /** + * Maps raw companion meta-schema name to raw main schema name, contributed by + * structural-detection delegates via {@link #addPreScannedInstance}. + * E.g. {@code "PageMetadata" → "UserPage"} for each detected paged model. + * Each meta-schema is suppressed in {@link #suppressGenericSchemas} only when its + * corresponding main schema was actually removed in the same pass. + */ + 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 @@ -176,6 +185,34 @@ 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.put(rawMetaSchemaName, inst.schemaName); + } + } + // ========================================================================= // Lifecycle 1: preprocessOpenAPI // ========================================================================= @@ -384,7 +421,7 @@ public void substituteReturnType(CodegenOperation op, Context ctx) { * @return the (possibly mutated) model map */ public Map suppressGenericSchemas(Map objs, Context ctx) { - if (instanceRegistry.isEmpty()) { + if (instanceRegistry.isEmpty() && extraSuppressedMetaSchemas.isEmpty()) { return objs; } if (ctx.getAnnotationLibrary() != AnnotationLibrary.NONE) { @@ -396,6 +433,9 @@ public Map suppressGenericSchemas(Map 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(); @@ -414,10 +454,36 @@ public Map suppressGenericSchemas(Map objs // 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 its + // corresponding main schema was actually removed in the loop above, so that + // schema-mapped or still-referenced main schemas keep their meta schemas. + for (Map.Entry metaEntry : extraSuppressedMetaSchemas.entrySet()) { + String rawMeta = metaEntry.getKey(); + String rawMain = metaEntry.getValue(); + if (!suppressedRawNames.contains(rawMain)) { + // Main schema was not removed — keep the meta schema. + continue; + } + String transformedMeta = ctx.toModelName(rawMeta); + boolean referencedElsewhere = objs.values().stream() + .flatMap(mm -> mm.getModels().stream()) + .map(ModelMap::getModel) + .anyMatch(cm -> cm.imports.contains(transformedMeta)); + if (referencedElsewhere) { + 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; } 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 8dbd6893e7c1..023078d18475 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 @@ -204,7 +204,10 @@ public String getDescription() { // GenericSubstitutionSupport.Context implementation @Override public String fileExtension() { return "kt"; } - @Override public String toModelImport(String className) { return modelPackage() + "." + className; } + @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; @@ -1119,11 +1122,8 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation // Build pageable annotations, add Pageable imports, and remove page/size/sort params. pageableSupport.processPageableAnnotations(codegenOperation, this, "[", "]"); - // If substituteGenericPagedModel is enabled, replace paged-model return types - // with org.springframework.data.web.PagedModel. - pageableSupport.substituteReturnType(codegenOperation, this); - - // Replace operation return types for generic schema patterns (genericPatterns feature). + // 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; @@ -1140,6 +1140,8 @@ public void preprocessOpenAPI(OpenAPI openAPI) { pageableSupport.preprocessOpenAPI(openAPI, this, SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY, "kt"); + pageableSupport.contributeToGenericSubstitution(genericSubstitutionSupport, this); + genericSubstitutionSupport.preprocessOpenAPI(openAPI, this); if (useEnumValueInterface) { @@ -1308,10 +1310,6 @@ public Map postProcessAllModels(Map objs) } } - if (pageableSupport.isSubstituteGenericPagedModel()) { - objs = pageableSupport.suppressPagedModels(objs, this); - } - objs = genericSubstitutionSupport.suppressGenericSchemas(objs, this); return objs; 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 cf8a641102ee..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 @@ -222,7 +222,10 @@ public enum RequestMappingMode { // GenericSubstitutionSupport.Context implementation @Override public String fileExtension() { return "java"; } - @Override public String toModelImport(String className) { return modelPackage() + "." + className; } + @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; } // Holds scan results for Spring Pageable features (populated during preprocessOpenAPI) @@ -928,6 +931,8 @@ public void preprocessOpenAPI(OpenAPI openAPI) { pageableSupport.preprocessOpenAPI(openAPI, this, SPRING_HTTP_INTERFACE, "java"); + pageableSupport.contributeToGenericSubstitution(genericSubstitutionSupport, this); + genericSubstitutionSupport.preprocessOpenAPI(openAPI, this); if (useEnumValueInterface) { @@ -1339,11 +1344,8 @@ 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. - pageableSupport.substituteReturnType(codegenOperation, this); - - // Replace operation return types for generic schema patterns (genericPatterns feature). + // 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; @@ -1400,8 +1402,6 @@ public Map postProcessAllModels(Map objs) } } - objs = pageableSupport.suppressPagedModels(objs, this); - objs = genericSubstitutionSupport.suppressGenericSchemas(objs, this); return objs; 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 index cb6b36321df2..4ede6f174697 100644 --- 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 @@ -63,13 +63,20 @@ * following a naming convention. It does not suppress companion metadata schemas. * * - *

    Non-overlap guarantee

    - *

    When both features are active, they process the same operation sequentially: - * {@code pageableSupport.substituteReturnType()} runs first (in {@code fromOperation}), - * then {@code genericSubstitutionSupport.substituteReturnType()} runs. If the first has - * already replaced {@code returnBaseType} with e.g. {@code "PagedModel"}, the second lookup - * into the {@code genericPatterns} registry will find no entry for that new name and will - * skip — so double-substitution cannot occur.

    + *

    Integration with {@link GenericSubstitutionSupport}

    + *

    When {@code substituteGenericPagedModel} is active, call + * {@link #contributeToGenericSubstitution} after {@link #preprocessOpenAPI} and + * before {@link GenericSubstitutionSupport#preprocessOpenAPI}. This converts each + * structurally-detected {@link PagedModelScanUtils.DetectedPagedModel} into a + * {@link GenericSchemaScanUtils.GenericInstance} and registers it in the + * {@code GenericSubstitutionSupport} instance registry. Downstream, all return-type + * substitution and model suppression (including the companion {@code PageMetadata}-style + * schema) are then handled by {@code GenericSubstitutionSupport} uniformly, gaining + * property-level substitution and the {@code isStillReferenced} safety check for free.

    + * + *

    The {@link #substituteReturnType} and {@link #suppressPagedModels} methods remain + * for backward compatibility but are no longer invoked by the built-in generators once + * {@link #contributeToGenericSubstitution} is wired in.

    */ public final class SpringPageableSupport { @@ -224,6 +231,55 @@ public void preprocessOpenAPI(OpenAPI openAPI, Context ctx, } } + /** + * 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 + true, // content is an array in PagedModel + 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. From f316dd5cc31a778be4dbf6230efc30e26fd49b54 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Sun, 31 May 2026 02:01:49 +0200 Subject: [PATCH 35/43] refactor: post-review fixes for genericPatterns + paged-model unification Bugs: - substituteReturnType now preserves outer container (List becomes List> instead of silently dropping the List). - extraSuppressedMetaSchemas changed from Map to Map>: a meta-schema is suppressed only when ALL associated mains were removed. Sibling protection (e.g. one main kept via schemaMapping) now correctly keeps the meta. - isStillReferenced now also checks model.imports, requiredVars, optionalVars, and allVars (matching substitutePropertyTypes); used as the meta-schema safety check too. - findRefInProperties: removed dead fallback branch that always returned null even when ModelUtils.getReferencedSchema resolved a schema. - findVaryingRefProperty: skip candidates with absent/non-ref members instead of treating null as a distinct ref value. Readability: - Dropped dead methods SpringPageableSupport.substituteReturnType / suppressPagedModels and their now-unused imports (ModelMap, ModelsMap). - Updated class Javadocs in GenericSubstitutionSupport, SpringPageableSupport, and DetectedPagedModel to describe the current unified architecture. - Log a warning when toModelName re-keying collapses two raw schema names to the same transformed name (previously a silent registry drop). - Documented substring-collision invariant on the dataType.replace() property substitution path. - autoDetectPagination return value was always discarded; made void. - GenericSchemaScanUtils: removed dead isArray ternary, unreachable return, fixed log placeholder rendering, documented firstTypeArg empty case, dropped unused openAPI parameter from findRefInProperties. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../languages/GenericSchemaScanUtils.java | 42 ++--- .../languages/GenericSubstitutionSupport.java | 116 +++++++++----- .../languages/PagedModelScanUtils.java | 37 +++-- .../languages/SpringPageableSupport.java | 150 +++--------------- 4 files changed, 140 insertions(+), 205 deletions(-) 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 index 957c570d15ca..45cdfaee4133 100644 --- 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 @@ -18,7 +18,6 @@ import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.media.Schema; -import org.openapitools.codegen.utils.ModelUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -171,6 +170,10 @@ public GenericInstance(String schemaName, String genericClassName, String generi /** * 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(); @@ -364,7 +367,7 @@ public static List scanWithPatterns(OpenAPI openAPI, String typeParamName = slotEntry.getValue(); // Try $ref slot first - String ref = findRefInProperties(props, slotPropName, openAPI); + String ref = findRefInProperties(props, slotPropName); if (ref != null) { typeArgs.put(slotPropName, extractSchemaNameFromRef(ref)); slotTypeParams.put(slotPropName, typeParamName); @@ -413,7 +416,7 @@ public static List scanWithPatterns(OpenAPI openAPI, LOGGER.debug("GenericSchemaScanUtils Tier2: schema '{}' matched pattern '{}' → {}<{}>", schemaName, pattern.suffix != null ? ("suffix=" + pattern.suffix) : ("prefix=" + pattern.prefix), - genericClassName, typeArgs.values()); + genericClassName, String.join(", ", typeArgs.values())); break; // first matching pattern wins } } @@ -538,19 +541,16 @@ static Map resolveProperties(Schema schema, OpenAPI openAPI) } /** - * Finds the {@code $ref} string of a property in the resolved properties. - * Returns {@code null} if the property is absent or is not a {@code $ref}. + * 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, - OpenAPI openAPI) { + private static String findRefInProperties(Map props, String propName) { if (props == null) return null; Schema prop = (Schema) props.get(propName); if (prop == null) return null; - if (prop.get$ref() != null) return prop.get$ref(); - // Might be inlined — check via ModelUtils to handle $ref resolution - Schema resolved = ModelUtils.getReferencedSchema(openAPI, prop); - return resolved != null && resolved != prop ? prop.get$ref() : null; + return prop.get$ref(); } /** @@ -714,14 +714,18 @@ private static String findVaryingRefProperty(List schemaNames, String varyingProp = null; for (String candidate : candidates) { Set refs = new HashSet<>(); + boolean abort = false; for (String name : schemaNames) { Schema schema = allSchemas.get(name); - if (schema.getProperties() == null) { refs.add(null); break; } + if (schema.getProperties() == null) { abort = true; break; } Schema prop = (Schema) schema.getProperties().get(candidate); - refs.add(prop != null ? prop.get$ref() : null); + String ref = prop != null ? prop.get$ref() : null; + if (ref == null) { abort = true; break; } + refs.add(ref); } + if (abort) continue; if (refs.size() == schemaNames.size()) { - // All members have this property, all different + // Every member has this property as a $ref and all are distinct if (varyingProp != null) { return null; // more than one varying property — not a simple generic } @@ -742,17 +746,19 @@ private static String commonSuffix(List names) { return first.substring(first.length() - len + 1); } } - return first; + // 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, List schemaNames) { - boolean isArray = false; // Tier 3 only handles $ref properties - String slotKey = isArray ? "slotArray" : "slot"; + // Tier 3 cluster discovery currently only follows $ref properties, so the slot is + // never an array. Inline as "slot:" and keep the door open for a future array variant. return "genericPatterns:\n" + " - suffix: " + suggestedSuffix + "\n" + " genericClass: \n" - + " " + slotKey + ": " + slotProperty + "\n" + + " slot: " + slotProperty + "\n" + " # Schemas matched: " + String.join(", ", schemaNames); } 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 index 2c40e29e6317..fad613bfbf67 100644 --- 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 @@ -61,24 +61,27 @@ * * *

    Relationship to {@link SpringPageableSupport}

    - *

    This class and {@link SpringPageableSupport} both perform return-type substitution - * for generic wrapper schemas, but they are complementary, not redundant:

    + *

    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):

    *
      - *
    • This class ({@code genericPatterns} config) 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. It suppresses the matched wrapper schema but not any companion - * metadata schemas.
    • - *
    • {@link SpringPageableSupport} ({@code substituteGenericPagedModel} flag) uses - * structural detection: it identifies paged-model schemas by shape, requires no - * naming convention, and additionally suppresses the companion {@code PageMetadata}-style - * schema. It is specialised for the Spring {@code PagedModel} use case.
    • + *
    • {@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.
    • *
    * - *

    When both features are active on the same spec, {@link SpringPageableSupport} runs first - * inside {@code fromOperation}. If it replaces a return type, this class will not find the - * original schema name in its registry (because {@code returnBaseType} has already changed), - * so double-substitution cannot occur.

    + *

    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 { @@ -158,13 +161,15 @@ public interface Context { new LinkedHashMap<>(); /** - * Maps raw companion meta-schema name to raw main schema name, contributed by - * structural-detection delegates via {@link #addPreScannedInstance}. - * E.g. {@code "PageMetadata" → "UserPage"} for each detected paged model. - * Each meta-schema is suppressed in {@link #suppressGenericSchemas} only when its - * corresponding main schema was actually removed in the same pass. + * 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<>(); + private final Map> extraSuppressedMetaSchemas = new LinkedHashMap<>(); /** * Bundle data for each Mode B class, keyed by simple class name (e.g. {@code "ApiResponse"}). @@ -209,7 +214,9 @@ public void addPreScannedInstance(GenericSchemaScanUtils.GenericInstance inst, String rawMetaSchemaName) { instanceRegistry.put(inst.schemaName, inst); if (rawMetaSchemaName != null) { - extraSuppressedMetaSchemas.put(rawMetaSchemaName, inst.schemaName); + extraSuppressedMetaSchemas + .computeIfAbsent(rawMetaSchemaName, k -> new LinkedHashSet<>()) + .add(inst.schemaName); } } @@ -272,7 +279,14 @@ public void preprocessOpenAPI(OpenAPI openAPI, Context ctx) { Map reKeyed = new LinkedHashMap<>(); for (Map.Entry entry : instanceRegistry.entrySet()) { String transformedKey = ctx.toModelName(entry.getKey()); - reKeyed.put(transformedKey, entry.getValue()); + 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); @@ -375,11 +389,19 @@ public void substituteReturnType(CodegenOperation op, Context ctx) { } String oldType = op.returnType; - String newType = buildGenericTypeName(inst, ctx, new HashSet<>()); + 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; - op.returnContainer = null; // generic wrapper is not a container collectImportsToAdd(inst, ctx, op.imports, new HashSet<>()); if (ctx.getAnnotationLibrary() == AnnotationLibrary.NONE) { @@ -461,22 +483,18 @@ public Map suppressGenericSchemas(Map objs } // Suppress companion meta-schemas contributed by pre-scan delegates (e.g. - // substituteGenericPagedModel). A meta-schema is only suppressed when its - // corresponding main schema was actually removed in the loop above, so that - // schema-mapped or still-referenced main schemas keep their meta schemas. - for (Map.Entry metaEntry : extraSuppressedMetaSchemas.entrySet()) { + // 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(); - String rawMain = metaEntry.getValue(); - if (!suppressedRawNames.contains(rawMain)) { - // Main schema was not removed — keep the meta schema. + 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); - boolean referencedElsewhere = objs.values().stream() - .flatMap(mm -> mm.getModels().stream()) - .map(ModelMap::getModel) - .anyMatch(cm -> cm.imports.contains(transformedMeta)); - if (referencedElsewhere) { + if (isStillReferenced(transformedMeta, objs)) { LOGGER.info("GenericSubstitutionSupport: keeping companion meta-schema '{}'" + " — still referenced by remaining models", rawMeta); } else if (objs.remove(rawMeta) != null) { @@ -747,6 +765,13 @@ private void substitutePropertyTypes(Map objs, Context ctx) { // 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); @@ -842,8 +867,11 @@ private void syncModelsMapImports(ModelsMap modelsMap, Set removed, /** * Returns {@code true} if any model in {@code objs} still references the given - * transformed instance name — either via a property {@code baseType} that was not - * substituted or via {@code model.parent} (allOf inheritance). + * 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).

    @@ -855,7 +883,15 @@ private boolean isStillReferenced(String transformedKey, Map if (transformedKey.equals(model.parent)) { return true; } - for (CodegenProperty prop : model.vars) { + 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; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java index f948e4da1d3e..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 @@ -64,38 +64,41 @@ private PagedModelScanUtils() {} * *

    Two name variants are stored for each schema:

    *
      - *
    • transformed ({@code schemaName} / {@code metaSchemaName}) — the model name - * after the generator's {@code toModelName()} has been applied. These are the names - * that appear in codegen-operation imports and {@code CodegenModel.imports}, so they - * must be used for import removal / import-presence checks.
    • *
    • raw ({@code rawSchemaName} / {@code rawMetaSchemaName}) — the original - * OpenAPI component-schema name. {@code DefaultGenerator} keys {@code allProcessedModels} - * (the {@code objs} map passed to {@code postProcessAllModels}) by the raw - * schema name, so these values must be used for {@code objs.remove()} calls.
    • + * 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 {@link #scanPagedModels(OpenAPI)} is used (no transform), the raw and transformed - * names are identical. When {@link #scanPagedModels(OpenAPI, UnaryOperator)} is used, they - * may differ (e.g. {@code rawSchemaName="UserPage"}, {@code schemaName="UserPageDto"}).

    + *

    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 (for {@code objs.remove}). - * @param rawMetaSchemaName Raw OpenAPI schema name of the pagination-metadata schema - * (for {@code objs.remove}), 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 — use for import removal / import-presence checks. */ + /** Transformed model name. Useful for matching against {@code returnBaseType}. */ public final String schemaName; public final String itemSchemaName; - /** Transformed meta model name — use for import-presence checks. */ + /** 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}. */ + /** Raw OpenAPI schema name. Use for {@code objs.remove()} in {@code postProcessAllModels}. */ public final String rawSchemaName; - /** Raw OpenAPI meta schema name — use for {@code objs.remove()} in {@code postProcessAllModels}. */ + /** Raw OpenAPI meta schema name. Use for {@code objs.remove()} in {@code postProcessAllModels}. */ public final String rawMetaSchemaName; /** diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableSupport.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableSupport.java index 4ede6f174697..15bb28e60f4d 100644 --- 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 @@ -24,8 +24,6 @@ import org.openapitools.codegen.CodegenOperation; 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; @@ -48,35 +46,22 @@ * language-specific logic.

    * *

    Relationship to {@link GenericSubstitutionSupport}

    - *

    This class and {@link GenericSubstitutionSupport} both perform return-type substitution, - * but they are complementary, not redundant:

    - *
      - *
    • This class ({@code substituteGenericPagedModel} flag) uses - * structural detection via {@link PagedModelScanUtils}: it finds paged-model - * schemas by shape (a {@code content} array property + a pagination-metadata {@code $ref} - * property) regardless of naming conventions. It also suppresses the companion - * {@code PageMetadata}-style schema when it is no longer referenced. No naming convention - * or pattern config is required — just one boolean flag.
    • - *
    • {@link GenericSubstitutionSupport} ({@code genericPatterns} config) uses - * name-based pattern matching (suffix / prefix / vendor extensions). It can - * target any generic class and any number of type parameters, but relies on schemas - * following a naming convention. It does not suppress companion metadata schemas.
    • - *
    + *

    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.

    * - *

    Integration with {@link GenericSubstitutionSupport}

    - *

    When {@code substituteGenericPagedModel} is active, call - * {@link #contributeToGenericSubstitution} after {@link #preprocessOpenAPI} and - * before {@link GenericSubstitutionSupport#preprocessOpenAPI}. This converts each - * structurally-detected {@link PagedModelScanUtils.DetectedPagedModel} into a - * {@link GenericSchemaScanUtils.GenericInstance} and registers it in the - * {@code GenericSubstitutionSupport} instance registry. Downstream, all return-type - * substitution and model suppression (including the companion {@code PageMetadata}-style - * schema) are then handled by {@code GenericSubstitutionSupport} uniformly, gaining - * property-level substitution and the {@code isStillReferenced} safety check for free.

    - * - *

    The {@link #substituteReturnType} and {@link #suppressPagedModels} methods remain - * for backward compatibility but are no longer invoked by the built-in generators once - * {@link #contributeToGenericSubstitution} is wired in.

    + *

    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 { @@ -272,8 +257,8 @@ public void contributeToGenericSubstitution(GenericSubstitutionSupport genericSu false, // Mode A — external / supporting-file class typeArgs, slotTypeParams, - "content", // slot property name - true, // content is an array in PagedModel + "content", // slot property name + false, // slotIsArray is only consumed by Mode B class generation Collections.emptyList() ); genericSupport.addPreScannedInstance(inst, d.rawMetaSchemaName); @@ -291,15 +276,14 @@ public void contributeToGenericSubstitution(GenericSubstitutionSupport genericSu * * @param operation the raw OpenAPI operation * @param library the active library name - * @return {@code true} if the operation was (or was already) marked as paginated */ - public boolean autoDetectPagination(Operation operation, String library) { + public void autoDetectPagination(Operation operation, String library) { if (!SpringCodegen.SPRING_BOOT.equals(library) || !autoXSpringPaginated) { - return false; + return; } if (operation.getExtensions() != null && Boolean.FALSE.equals(operation.getExtensions().get("x-spring-paginated"))) { - return false; + return; } if (operation.getParameters() != null) { Set paramNames = operation.getParameters().stream() @@ -310,10 +294,8 @@ public boolean autoDetectPagination(Operation operation, String library) { operation.setExtensions(new HashMap<>()); } operation.getExtensions().put("x-spring-paginated", Boolean.TRUE); - return true; } } - return false; } /** @@ -402,96 +384,4 @@ public void processPageableAnnotations(CodegenOperation codegenOperation, Contex codegenOperation.vendorExtensions.put("x-pageable-extra-annotation", pageableAnnotations); } } - - /** - * Replaces the operation's return type with {@code PagedModel} when the return type - * is a detected paged-model schema. - * - *

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

    - * - * @param codegenOperation the codegen operation whose return type may be replaced - * @param ctx callback access to the generator's state - */ - public void substituteReturnType(CodegenOperation codegenOperation, Context ctx) { - if (!substituteGenericPagedModel || pagedModelRegistry.isEmpty() - || codegenOperation.returnBaseType == null) { - return; - } - PagedModelScanUtils.DetectedPagedModel detected = - pagedModelRegistry.get(codegenOperation.returnBaseType); - if (detected == null) { - return; - } - String oldType = codegenOperation.returnType; - // Run through toModelName so that schemaMappings (e.g. User → com.example.MyUser) - // are honoured: the mapped name is used both in the type arg and for import resolution. - String itemType = ctx.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; - codegenOperation.imports.add(itemType); - codegenOperation.imports.add(pagedModelClassName); - if (ctx.getAnnotationLibrary() == AnnotationLibrary.NONE) { - codegenOperation.imports.remove(detected.schemaName); - } - LOGGER.info("substituteGenericPagedModel: operation '{}': replacing return type '{}' with {}<{}>", - codegenOperation.operationId, oldType, pagedModelClassName, itemType); - } - - /** - * Suppresses detected paged-model schemas (and their orphaned metadata schemas) from - * the model map when {@code annotationLibrary=none}. - * - *

    When annotations are generated the paged-model schemas are still referenced by - * {@code @ApiResponse}, so they must be kept.

    - * - * @param objs the full model map, as received by {@code postProcessAllModels} - * @param ctx callback access to the generator's state - * @return the (possibly mutated) model map - */ - public Map suppressPagedModels(Map objs, Context ctx) { - if (!substituteGenericPagedModel || pagedModelRegistry.isEmpty()) { - return objs; - } - if (ctx.getAnnotationLibrary() != AnnotationLibrary.NONE) { - LOGGER.info("substituteGenericPagedModel: keeping paged-model schemas (annotationLibrary={}) " - + "— @ApiResponse annotations reference them", - ctx.getAnnotationLibrary().toCliOptValue()); - return objs; - } - - Set metaSchemasToCheck = new HashSet<>(); - for (PagedModelScanUtils.DetectedPagedModel detected : pagedModelRegistry.values()) { - if (detected.metaSchemaName != null) { - metaSchemasToCheck.add(detected.metaSchemaName); - } - } - // Remove paged schemas first so that reference checks below reflect 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 schema still references them. - 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); - } - } - return objs; - } } From de99a895a509b64f8ff8342adffce10347260634 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Sun, 31 May 2026 02:18:11 +0200 Subject: [PATCH 36/43] docs: document genericPatterns and discoverGenericPatterns options Regenerates docs/generators/{spring,kotlin-spring,java-camel}.md to include the genericPatterns and discoverGenericPatterns option rows, and adds a new user guide at docs/customization-genericPatterns.md covering pattern forms, Mode A vs Mode B, vendor-extension overrides, the discovery workflow, and interactions with schemaMapping/importMapping/modelNameSuffix and annotationLibrary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/customization-genericPatterns.md | 174 ++++++++++++++++++++++++++ docs/generators/java-camel.md | 2 + docs/generators/kotlin-spring.md | 2 + docs/generators/spring.md | 2 + 4 files changed, 180 insertions(+) create mode 100644 docs/customization-genericPatterns.md diff --git a/docs/customization-genericPatterns.md b/docs/customization-genericPatterns.md new file mode 100644 index 000000000000..a8d954852f77 --- /dev/null +++ b/docs/customization-genericPatterns.md @@ -0,0 +1,174 @@ +--- +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-`$ref` slot** patterns on **flat-object** schemas. It will **not** suggest: + +* **`Page` / `PagedModel`-style schemas** — the varying property in a paged response is typically an `array` of `$ref` (e.g. `content: { type: array, items: { $ref: ... } }`), which Tier-3 discovery skips. Schemas defined via `allOf` are also skipped. For these, enable [`substituteGenericPagedModel`](#companion-feature-substitutegenericpagedmodel) instead — it performs *structural* paged-model detection that handles both the flat-object and `allOf` forms and the `array[$ref] + metadata-$ref` shape. +* **Multi-slot generics** (e.g. `Result`) — only single-slot families are auto-detected. +* **Schemas that don't share a common name suffix** — clustering also needs a stable naming convention to suggest a usable pattern. + +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 bfec99c38003..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| diff --git a/docs/generators/kotlin-spring.md b/docs/generators/kotlin-spring.md index b3878c22a6e2..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| diff --git a/docs/generators/spring.md b/docs/generators/spring.md index 1a444e6ef6a6..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| From 3f76733f7f0f2fab12afbedfe3262fea5ba0acd8 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Sun, 31 May 2026 02:29:56 +0200 Subject: [PATCH 37/43] feat(spring): discoverGenericPatterns now suggests slotArray patterns (Page) Tier-3 structural cluster discovery previously only considered plain $ref properties as candidate slots, so it ignored the very common Page / PagedModel shape where the varying property is an array of $ref (e.g. content: { type: array, items: { $ref: ... } }). findVaryingRefProperty now returns a VaryingProperty struct (name + isArray flag) and considers both plain-$ref and array[$ref] candidate properties. ClusterSuggestion gains an isArraySlot field (backward-compatible additional constructor), and buildSuggestedConfig emits `slotArray:` vs `slot:` accordingly so the suggested YAML is directly usable. Two new tests cover the happy path (UserPage/PetPage cluster correctly producing slotArray: content) and the multi-varying rejection path (paged schemas where both content and metadata refs vary do not cluster). All 178 generic-related tests pass. The allOf paged-model case remains out of scope for discovery (allOf returns null fingerprint by design); substituteGenericPagedModel handles that variant structurally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/customization-genericPatterns.md | 4 +- .../languages/GenericSchemaScanUtils.java | 101 +++++++++++++----- .../languages/GenericSchemaScanUtilsTest.java | 57 ++++++++++ 3 files changed, 132 insertions(+), 30 deletions(-) diff --git a/docs/customization-genericPatterns.md b/docs/customization-genericPatterns.md index a8d954852f77..655a9b052e2d 100644 --- a/docs/customization-genericPatterns.md +++ b/docs/customization-genericPatterns.md @@ -130,9 +130,9 @@ During the next generation, the tool scans for **structural clusters** — group ### What discovery does *not* find -Discovery is intentionally limited to **single-`$ref` slot** patterns on **flat-object** schemas. It will **not** suggest: +Discovery is intentionally limited to **single-slot** patterns on **flat-object** schemas. 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 }`). It will **not** suggest: -* **`Page` / `PagedModel`-style schemas** — the varying property in a paged response is typically an `array` of `$ref` (e.g. `content: { type: array, items: { $ref: ... } }`), which Tier-3 discovery skips. Schemas defined via `allOf` are also skipped. For these, enable [`substituteGenericPagedModel`](#companion-feature-substitutegenericpagedmodel) instead — it performs *structural* paged-model detection that handles both the flat-object and `allOf` forms and the `array[$ref] + metadata-$ref` shape. +* **`allOf`-based schemas** — schemas defined as `allOf` are skipped during structural fingerprinting. For paged models expressed via `allOf` (common when extending a `PageMetadata` base), enable [`substituteGenericPagedModel`](#companion-feature-substitutegenericpagedmodel) — its structural detector handles both the flat-object and `allOf` forms. * **Multi-slot generics** (e.g. `Result`) — only single-slot families are auto-detected. * **Schemas that don't share a common name suffix** — clustering also needs a stable naming convention to suggest a usable pattern. 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 index 45cdfaee4133..a18de8187a34 100644 --- 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 @@ -189,6 +189,11 @@ public static final class ClusterSuggestion { 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. */ @@ -196,8 +201,15 @@ public static final class ClusterSuggestion { 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; } @@ -429,8 +441,9 @@ public static List scanWithPatterns(OpenAPI openAPI, /** * 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 {@code $ref} property - * which varies across members. + * 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 @@ -464,7 +477,7 @@ public static List discoverClusters(OpenAPI openAPI, byFingerprint.computeIfAbsent(fp, k -> new ArrayList<>()).add(name); } - // For each group of 2+, look for the varying $ref property + // 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) { @@ -472,8 +485,8 @@ public static List discoverClusters(OpenAPI openAPI, } // Find the property whose $ref target differs across all members - String varyingProp = findVaryingRefProperty(names, allSchemas); - if (varyingProp == null) { + VaryingProperty varying = findVaryingRefProperty(names, allSchemas); + if (varying == null) { continue; } @@ -482,18 +495,20 @@ public static List discoverClusters(OpenAPI openAPI, for (String name : names) { Schema schema = allSchemas.get(name); if (schema.getProperties() == null) continue; - Schema prop = (Schema) schema.getProperties().get(varyingProp); - if (prop != null && prop.get$ref() != null) { - varyingTypes.add(extractSchemaNameFromRef(prop.get$ref())); + Schema prop = (Schema) schema.getProperties().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, varyingProp, names); + String suggestedConfig = buildSuggestedConfig(suggestedSuffix, varying.name, + varying.isArray, names); - result.add(new ClusterSuggestion(new ArrayList<>(names), varyingProp, - varyingTypes, suggestedConfig)); + result.add(new ClusterSuggestion(new ArrayList<>(names), varying.name, + varying.isArray, varyingTypes, suggestedConfig)); } return result; } @@ -692,47 +707,78 @@ private static String propertyTypeDescriptor(Schema prop) { 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 a $ref string read from a candidate 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} properties must be identical). + * 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.

    */ @SuppressWarnings("rawtypes") - private static String findVaryingRefProperty(List schemaNames, + private static VaryingProperty findVaryingRefProperty(List schemaNames, Map allSchemas) { if (schemaNames.isEmpty()) return null; Schema first = allSchemas.get(schemaNames.get(0)); if (first.getProperties() == null) return null; - List candidates = new ArrayList<>(); + // Collect candidate properties (plain $ref OR array[$ref]) from the first member + List candidates = new ArrayList<>(); for (Map.Entry entry : first.getProperties().entrySet()) { Schema prop = (Schema) entry.getValue(); - if (prop.get$ref() == null) continue; - candidates.add((String) entry.getKey()); + if (prop.get$ref() != null) { + candidates.add(new VaryingProperty((String) entry.getKey(), false)); + } else if ("array".equals(prop.getType()) && prop.getItems() != null + && prop.getItems().get$ref() != null) { + candidates.add(new VaryingProperty((String) entry.getKey(), true)); + } } - String varyingProp = null; - for (String candidate : candidates) { + VaryingProperty varying = null; + for (VaryingProperty candidate : candidates) { Set refs = new HashSet<>(); boolean abort = false; for (String name : schemaNames) { Schema schema = allSchemas.get(name); if (schema.getProperties() == null) { abort = true; break; } - Schema prop = (Schema) schema.getProperties().get(candidate); - String ref = prop != null ? prop.get$ref() : null; + Schema prop = (Schema) schema.getProperties().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 and all are distinct - if (varyingProp != null) { + // 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 } - varyingProp = candidate; + varying = candidate; } } - return varyingProp; + return varying; } private static String commonSuffix(List names) { @@ -752,13 +798,12 @@ private static String commonSuffix(List names) { } private static String buildSuggestedConfig(String suggestedSuffix, String slotProperty, - List schemaNames) { - // Tier 3 cluster discovery currently only follows $ref properties, so the slot is - // never an array. Inline as "slot:" and keep the door open for a future array variant. + boolean isArraySlot, List schemaNames) { + String slotKey = isArraySlot ? "slotArray" : "slot"; return "genericPatterns:\n" + " - suffix: " + suggestedSuffix + "\n" + " genericClass: \n" - + " slot: " + slotProperty + "\n" + + " " + slotKey + ": " + slotProperty + "\n" + " # Schemas matched: " + String.join(", ", schemaNames); } 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 index 04eac18f990d..861cc0e7a6b5 100644 --- 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 @@ -796,6 +796,63 @@ public void discoverClusters_singleSchema_doesNotFormCluster() { 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 // ========================================================================= From 7ae7924e6e178e7e180f91228b8dfd829f555701 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Mon, 1 Jun 2026 00:26:11 +0200 Subject: [PATCH 38/43] feat(spring): discoverGenericPatterns now supports allOf schemas Extends Tier-3 discovery in GenericSchemaScanUtils to cluster allOf-based schemas (e.g. event envelopes, paged-allOf shapes) in addition to flat objects. Mechanics: * buildStructuralFingerprint now handles allOf with at-most-one inline object entry. Fingerprint shape: `allOf|extends:|inline:`, with flat-object schemas prefixed `flat|` so the two never collide. * New helper getCandidateProperties() centralizes `properties for clustering` for both flat and allOf shapes; getAllOfExtendsFingerprint() canonicalizes the extends-bases. * findVaryingRefProperty refactored to consume a pre-resolved Map built once by discoverClusters, so the same code path serves flat-object and allOf members. * allOf members sharing extends-bases but with multi-slot variation, or with >1 inline object entries, remain out of scope. Tests: * 5 new tests in GenericSchemaScanUtilsTest (event-envelope happy path, paged-allOf happy path, different-extends negative, mixed-flat-and-allOf negative, two-inline-entries edge case). * Replaced `allOfSchemas_excludedFromClustering` (now obsolete) with the corresponding positive assertion. * Updated buildStructuralFingerprint_allOfSchema test to verify the composite fingerprint shape. Docs: * Removed the `allOf-based schemas` exclusion bullet from docs/customization-genericPatterns.md; added a note recommending substituteGenericPagedModel for pure pagination cases (it remains auto-applied and removes the orphaned metadata schemas). All 183 generics + paged-model tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/customization-genericPatterns.md | 6 +- .../languages/GenericSchemaScanUtils.java | 134 +++++++++++--- .../languages/GenericSchemaScanUtilsTest.java | 170 +++++++++++++++++- 3 files changed, 275 insertions(+), 35 deletions(-) diff --git a/docs/customization-genericPatterns.md b/docs/customization-genericPatterns.md index 655a9b052e2d..cc3ece6abf46 100644 --- a/docs/customization-genericPatterns.md +++ b/docs/customization-genericPatterns.md @@ -130,12 +130,14 @@ During the next generation, the tool scans for **structural clusters** — group ### What discovery does *not* find -Discovery is intentionally limited to **single-slot** patterns on **flat-object** schemas. 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 }`). It will **not** suggest: +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: -* **`allOf`-based schemas** — schemas defined as `allOf` are skipped during structural fingerprinting. For paged models expressed via `allOf` (common when extending a `PageMetadata` base), enable [`substituteGenericPagedModel`](#companion-feature-substitutegenericpagedmodel) — its structural detector handles both the flat-object and `allOf` forms. * **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` 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 index a18de8187a34..b56796878391 100644 --- 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 @@ -462,7 +462,10 @@ public static List discoverClusters(OpenAPI openAPI, Map allSchemas = openAPI.getComponents().getSchemas(); - // Build fingerprint → list of schema names + // 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(); @@ -472,8 +475,13 @@ public static List discoverClusters(OpenAPI openAPI, Schema schema = entry.getValue(); String fp = buildStructuralFingerprint(schema); if (fp == null) { - continue; // allOf or no properties + 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); } @@ -485,7 +493,7 @@ public static List discoverClusters(OpenAPI openAPI, } // Find the property whose $ref target differs across all members - VaryingProperty varying = findVaryingRefProperty(names, allSchemas); + VaryingProperty varying = findVaryingRefProperty(names, candidatePropsByName); if (varying == null) { continue; } @@ -493,9 +501,8 @@ public static List discoverClusters(OpenAPI openAPI, // Collect all varying types List varyingTypes = new ArrayList<>(); for (String name : names) { - Schema schema = allSchemas.get(name); - if (schema.getProperties() == null) continue; - Schema prop = (Schema) schema.getProperties().get(varying.name); + Map props = candidatePropsByName.get(name); + Schema prop = props.get(varying.name); String ref = extractVaryingRef(prop, varying.isArray); if (ref != null) { varyingTypes.add(extractSchemaNameFromRef(ref)); @@ -673,24 +680,91 @@ private static GenericProperty buildNonSlotProperty(String name, Schema propS // ========================================================================= /** - * Builds a canonical structural fingerprint for a flat-object schema. - * Returns {@code null} if the schema is an allOf or has no properties. + * Returns the property map that Tier-3 clustering should fingerprint and inspect for + * a varying slot. * - *

    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.

    + *
      + *
    • 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).

    */ - @SuppressWarnings("rawtypes") static String buildStructuralFingerprint(Schema schema) { - if (schema.getAllOf() != null || schema.getProperties() == null - || schema.getProperties().isEmpty()) { + Map props = getCandidateProperties(schema); + if (props == null || props.isEmpty()) { return null; } - return schema.getProperties().entrySet().stream() + String propsFp = props.entrySet().stream() .sorted(Map.Entry.comparingByKey()) - .map(e -> e.getKey() + ":" + propertyTypeDescriptor((Schema) e.getValue())) + .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") @@ -715,7 +789,7 @@ private static final class VaryingProperty { } /** - * Returns a $ref string read from a candidate property — either {@code prop.get$ref()} + * 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. */ @@ -737,23 +811,27 @@ private static String extractVaryingRef(Schema prop, boolean isArray) { *

    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 allSchemas) { + Map> candidatePropsByName) { if (schemaNames.isEmpty()) return null; - Schema first = allSchemas.get(schemaNames.get(0)); - if (first.getProperties() == null) 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 : first.getProperties().entrySet()) { - Schema prop = (Schema) entry.getValue(); + for (Map.Entry entry : firstProps.entrySet()) { + Schema prop = entry.getValue(); if (prop.get$ref() != null) { - candidates.add(new VaryingProperty((String) entry.getKey(), false)); + 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((String) entry.getKey(), true)); + candidates.add(new VaryingProperty(entry.getKey(), true)); } } @@ -762,9 +840,9 @@ private static VaryingProperty findVaryingRefProperty(List schemaNames, Set refs = new HashSet<>(); boolean abort = false; for (String name : schemaNames) { - Schema schema = allSchemas.get(name); - if (schema.getProperties() == null) { abort = true; break; } - Schema prop = (Schema) schema.getProperties().get(candidate.name); + 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); 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 index 861cc0e7a6b5..823b07696eec 100644 --- 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 @@ -122,6 +122,25 @@ private static Schema pageSchemaAllOf(String itemRefTarget) { 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(); @@ -247,9 +266,30 @@ public void buildStructuralFingerprint_differentStructure_returnsDifferentFinger } @Test - public void buildStructuralFingerprint_allOfSchema_returnsNull() { + 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"); - assertThat(GenericSchemaScanUtils.buildStructuralFingerprint(allOf)).isNull(); + 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 @@ -747,8 +787,11 @@ public void discoverClusters_excludedSchemas_notIncludedInClusters() { } @Test - public void discoverClusters_allOfSchemas_excludedFromClustering() { - // allOf schemas cannot be fingerprinted + 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")); @@ -760,7 +803,124 @@ public void discoverClusters_allOfSchemas_excludedFromClustering() { List suggestions = GenericSchemaScanUtils.discoverClusters(openAPI, Collections.emptySet()); - // allOf schemas return null fingerprint — they won't cluster + 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(); } From 0ae4e392dca17cf136e0332991ae42678e02c2bc Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Mon, 1 Jun 2026 00:52:11 +0200 Subject: [PATCH 39/43] fix(kotlin-spring,core): address 2 review findings on this branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) kotlin-spring dataClass.mustache: container inline enums no longer emit `override val value` when useEnumValueInterface=true. EnumValueInterfaceUtils.injectInPostProcessModelsEnum() adds ValuedEnum<...> to x-kotlin-implements only for `var.isEnum && !var.isContainer`, but the template emitted `override` for every enum. Container inline enums therefore generated `override val value` without anything to override and failed to compile with "overrides nothing". Gating the `override` token on `^isContainer` keeps the template in sync with the util. 2) DefaultGenerator + GenericSubstitutionSupport: prevent shared-bundle mutations from leaking across supporting-file iterations. DefaultGenerator.generateSupportingFiles called `config.prepareSupportingFile(bundle, support)` unconditionally — even when the file was filtered out by supportingFilesToGenerate or excluded by .openapi-generator-ignore. For Mode B generic class files (genericClass.mustache) this injected per-file `genericClassDef` into the shared bundle that a subsequent iteration would then read. Fixed with defense in depth: - DefaultGenerator now only calls prepareSupportingFile when the file will actually be rendered (shouldGenerate && ignoreProcessor.allowsFile). - GenericSubstitutionSupport.prepareSupportingFile now writes the "genericClassDef" key unconditionally (null clears stale state), so even back-to-back Mode B renders cannot inherit a previous file's class data. Pre-existing test failures (useEnumValueInterface_*, allOf-pageable scan) are unchanged by these fixes — verified by running the targeted suite on HEAD before applying the changes. Sample regen left out of this commit (will be addressed separately) — the regen also produces drift from unrelated branch state (Kotlin 1.9.25 bump, Jackson 3 setter nulls, new model additions) that is orthogonal to these two fixes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../java/org/openapitools/codegen/DefaultGenerator.java | 7 ++++++- .../codegen/languages/GenericSubstitutionSupport.java | 8 ++++---- .../src/main/resources/kotlin-spring/dataClass.mustache | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java index f2957a69b673..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,7 +1106,12 @@ private void generateSupportingFiles(List files, Map bundl shouldGenerate = supportingFilesToGenerate.contains(support.getDestinationFilename()); } - config.prepareSupportingFile(bundle, support); + // 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/languages/GenericSubstitutionSupport.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/GenericSubstitutionSupport.java index fad613bfbf67..16a2ccfddd76 100644 --- 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 @@ -526,10 +526,10 @@ public void prepareSupportingFile(Map bundle, SupportingFile sup int dot = dest.lastIndexOf('.'); if (dot < 0) return; String className = dest.substring(0, dot); - Map classData = modeBBundleData.get(className); - if (classData != null) { - bundle.put("genericClassDef", classData); - } + // 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)); } // ========================================================================= 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 7391dbc29b30..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 {{#useEnumValueInterface}}override {{/useEnumValueInterface}}val value: {{#isContainer}}{{#items}}{{{dataType}}}{{/items}}{{/isContainer}}{{^isContainer}}{{{dataType}}}{{/isContainer}}) {{#vendorExtensions.x-kotlin-implements}}{{#-first}}: {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}} {{/vendorExtensions.x-kotlin-implements}}{ + 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}}; From 50e723aa2ff46c382c9a085ed02f7ad54eb66be0 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Mon, 1 Jun 2026 01:23:50 +0200 Subject: [PATCH 40/43] update samples --- .../org/openapitools/api/PetController.java | 6 +- .../java/org/openapitools/api/PetApi.java | 9 +- .../.openapi-generator/FILES | 3 +- .../.openapi-generator/VERSION | 2 +- .../build.gradle.kts | 1 + .../kotlin-springboot-generics/pom.xml | 10 +- .../org/openapitools/api/ObservabilityApi.kt | 2 +- .../kotlin/org/openapitools/api/PageApi.kt | 13 +- .../org/openapitools/api/ResponseApi.kt | 26 +++- .../kotlin/org/openapitools/api/ResultApi.kt | 15 +- .../kotlin/org/openapitools/api/SearchApi.kt | 2 +- .../kotlin/org/openapitools/api/VendorApi.kt | 2 +- .../kotlin/org/openapitools/model/LogEntry.kt | 5 + .../org/openapitools/model/LogEntryData.kt | 5 + .../org/openapitools/model/MetricsEntry.kt | 5 + .../openapitools/model/MetricsEntryData.kt | 5 + .../openapitools/model/NotificationBatch.kt | 38 +++++ .../kotlin/org/openapitools/model/Order.kt | 4 + .../org/openapitools/model/OrderDetails.kt | 44 ++++++ .../org/openapitools/model/PaymentError.kt | 3 + .../main/kotlin/org/openapitools/model/Pet.kt | 4 + .../org/openapitools/model/SearchResult.kt | 6 + .../kotlin/org/openapitools/model/User.kt | 5 + .../org/openapitools/model/ValidationError.kt | 3 + .../kotlin/org/openapitools/api/PetApi.kt | 2 +- .../.openapi-generator/FILES | 2 + .../.openapi-generator/VERSION | 2 +- .../openapitools/api/ObservabilityApi.java | 4 +- .../java/org/openapitools/api/PageApi.java | 21 ++- .../org/openapitools/api/ResponseApi.java | 39 ++++- .../java/org/openapitools/api/ResultApi.java | 22 ++- .../java/org/openapitools/api/SearchApi.java | 4 +- .../java/org/openapitools/api/VendorApi.java | 4 +- .../java/org/openapitools/model/LogEntry.java | 2 +- .../org/openapitools/model/LogEntryData.java | 2 +- .../org/openapitools/model/MetricsEntry.java | 2 +- .../openapitools/model/MetricsEntryData.java | 2 +- .../openapitools/model/NotificationBatch.java | 120 ++++++++++++++++ .../java/org/openapitools/model/Order.java | 2 +- .../org/openapitools/model/OrderDetails.java | 134 ++++++++++++++++++ .../java/org/openapitools/model/PageMeta.java | 2 +- .../org/openapitools/model/PaymentError.java | 2 +- .../main/java/org/openapitools/model/Pet.java | 2 +- .../org/openapitools/model/SearchResult.java | 4 +- .../java/org/openapitools/model/User.java | 2 +- .../openapitools/model/ValidationError.java | 2 +- .../java/org/openapitools/api/PetApi.java | 2 +- 47 files changed, 555 insertions(+), 43 deletions(-) create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/NotificationBatch.kt create mode 100644 samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/OrderDetails.kt create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/NotificationBatch.java create mode 100644 samples/server/petstore/springboot-generics/src/main/java/org/openapitools/model/OrderDetails.java 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 78cd3c0d5fec..1e637f43a1a8 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 @@ -125,7 +125,8 @@ 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 + @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 ); @@ -163,7 +164,8 @@ 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 + @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 ); 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 6d015c8e2890..4949b2dae998 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 @@ -127,7 +127,8 @@ 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 + @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 ); @@ -173,7 +174,8 @@ ResponseEntity> findPetsByTags( @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 + @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 ); @@ -250,7 +252,8 @@ ResponseEntity getPetById( ResponseEntity> listAllPets( @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 + @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, + @ParameterObject final Pageable pageable ); diff --git a/samples/server/petstore/kotlin-springboot-generics/.openapi-generator/FILES b/samples/server/petstore/kotlin-springboot-generics/.openapi-generator/FILES index ec097a2f9dc1..a571abd9d2ea 100644 --- a/samples/server/petstore/kotlin-springboot-generics/.openapi-generator/FILES +++ b/samples/server/petstore/kotlin-springboot-generics/.openapi-generator/FILES @@ -1,4 +1,3 @@ -.openapi-generator-ignore README.md build.gradle.kts gradle/wrapper/gradle-wrapper.jar @@ -21,7 +20,9 @@ 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 diff --git a/samples/server/petstore/kotlin-springboot-generics/.openapi-generator/VERSION b/samples/server/petstore/kotlin-springboot-generics/.openapi-generator/VERSION index f7962df3e243..ca7bf6e46889 100644 --- a/samples/server/petstore/kotlin-springboot-generics/.openapi-generator/VERSION +++ b/samples/server/petstore/kotlin-springboot-generics/.openapi-generator/VERSION @@ -1 +1 @@ -7.22.0-SNAPSHOT +7.23.0-SNAPSHOT diff --git a/samples/server/petstore/kotlin-springboot-generics/build.gradle.kts b/samples/server/petstore/kotlin-springboot-generics/build.gradle.kts index dff0fa211c8d..30b6a49c9bf6 100644 --- a/samples/server/petstore/kotlin-springboot-generics/build.gradle.kts +++ b/samples/server/petstore/kotlin-springboot-generics/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { 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") diff --git a/samples/server/petstore/kotlin-springboot-generics/pom.xml b/samples/server/petstore/kotlin-springboot-generics/pom.xml index 70890ca131cc..c83b8a04b7ef 100644 --- a/samples/server/petstore/kotlin-springboot-generics/pom.xml +++ b/samples/server/petstore/kotlin-springboot-generics/pom.xml @@ -8,9 +8,9 @@ 3.0.2 2.1.0 - 1.7.10 + 1.9.25 - 1.7.10 + 1.9.25 UTF-8 @@ -136,12 +136,18 @@ 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 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 index a4df561544f0..f6f9b994a8e1 100644 --- 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 @@ -1,5 +1,5 @@ /** - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * 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. */ 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 index 4e81f86cd2bd..050478b44c1d 100644 --- 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 @@ -1,10 +1,11 @@ /** - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * 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 @@ -48,6 +49,15 @@ interface PageApi { ): ResponseEntity> + @RequestMapping( + method = [RequestMethod.GET], + // "/users/responses/page" + value = [PATH_LIST_USER_RESPONSES], + produces = ["application/json"] + ) + fun listUserResponses(): ResponseEntity>> + + @RequestMapping( method = [RequestMethod.GET], // "/users" @@ -63,6 +73,7 @@ interface PageApi { //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 index 031cefda9585..5e2ae58d9e5f 100644 --- 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 @@ -1,12 +1,14 @@ /** - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * 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 @@ -37,6 +39,26 @@ import kotlin.collections.Map 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" @@ -72,6 +94,8 @@ interface ResponseApi { 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 index 805ec72c8305..a0b70376bbae 100644 --- 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 @@ -1,10 +1,11 @@ /** - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * 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 @@ -59,10 +60,22 @@ interface ResultApi { @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 index 1a33c6057d4a..9cda0e6ad083 100644 --- 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 @@ -1,5 +1,5 @@ /** - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * 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. */ 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 index b2a1bf4815ef..514dfc3f2e6a 100644 --- 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 @@ -1,5 +1,5 @@ /** - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * 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. */ 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 index deb41db44740..b42167d47af1 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -22,10 +24,13 @@ import jakarta.validation.Valid 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 { 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 index 806dacbbebc6..986c9dce4708 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -20,10 +22,13 @@ import jakarta.validation.Valid */ 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 { 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 index e3c3f4ba6cb4..a93f14016db2 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -22,10 +24,13 @@ import jakarta.validation.Valid 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 { 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 index 5e765ee69f91..58a3e63a0ab7 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -20,10 +22,13 @@ import jakarta.validation.Valid */ 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 { 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 index 694e221bc01b..a29b9e9acc10 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -22,8 +24,10 @@ 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 { 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/PaymentError.kt b/samples/server/petstore/kotlin-springboot-generics/src/main/kotlin/org/openapitools/model/PaymentError.kt index 9a6a584e626a..7faed5b49ba0 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -24,6 +26,7 @@ data class PaymentError( @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 { 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 index dd68be0d7b99..930bda1f2185 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -22,8 +24,10 @@ 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 { 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 index 1d01fe3da324..2d667a48a2c8 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -21,12 +23,16 @@ import jakarta.validation.Valid */ 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 { 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 index 67826fd30093..1a59f2849984 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -20,10 +22,13 @@ import jakarta.validation.Valid */ 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 { 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 index eed99960c567..7a1e6c6d008b 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -24,6 +26,7 @@ data class ValidationError( @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 { 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 a3471ad6f48a..8a82e645c868 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 @@ -132,7 +132,7 @@ interface PetApi { value = [PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF], produces = ["application/json"] ) - fun findPetsWithMinSizeConstraintFromAllOfRef(@ValidPageable(minSize = 5) pageable: Pageable): ResponseEntity> { + fun findPetsWithMinSizeConstraintFromAllOfRef(@ValidPageable() pageable: Pageable): ResponseEntity> { return ResponseEntity(HttpStatus.NOT_IMPLEMENTED) } diff --git a/samples/server/petstore/springboot-generics/.openapi-generator/FILES b/samples/server/petstore/springboot-generics/.openapi-generator/FILES index cc3aaae20115..1025fb014f46 100644 --- a/samples/server/petstore/springboot-generics/.openapi-generator/FILES +++ b/samples/server/petstore/springboot-generics/.openapi-generator/FILES @@ -13,7 +13,9 @@ 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 diff --git a/samples/server/petstore/springboot-generics/.openapi-generator/VERSION b/samples/server/petstore/springboot-generics/.openapi-generator/VERSION index f7962df3e243..ca7bf6e46889 100644 --- a/samples/server/petstore/springboot-generics/.openapi-generator/VERSION +++ b/samples/server/petstore/springboot-generics/.openapi-generator/VERSION @@ -1 +1 @@ -7.22.0-SNAPSHOT +7.23.0-SNAPSHOT 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 index 6f0e4d68c914..abb2d8bc12a7 100644 --- 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 @@ -1,5 +1,5 @@ /* - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * 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. */ @@ -18,7 +18,7 @@ import java.util.Optional; import jakarta.annotation.Generated; -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.22.0-SNAPSHOT") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") @Validated @RequestMapping("${openapi.openAPIPetstoreGenericsTest.base-path:}") public interface ObservabilityApi { 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 index de61ba8ef624..df195d1764e6 100644 --- 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 @@ -1,10 +1,11 @@ /* - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * 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; @@ -21,7 +22,7 @@ import java.util.Optional; import jakarta.annotation.Generated; -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.22.0-SNAPSHOT") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") @Validated @RequestMapping("${openapi.openAPIPetstoreGenericsTest.base-path:}") public interface PageApi { @@ -45,6 +46,22 @@ ResponseEntity> listPets( ); + 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) 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 index 72570f6ffd5b..bc0a585166be 100644 --- 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 @@ -1,12 +1,14 @@ /* - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * 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; @@ -21,11 +23,44 @@ import java.util.Optional; import jakarta.annotation.Generated; -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.22.0-SNAPSHOT") +@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) 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 index 25fa1e4a99f4..ed8638ac98c4 100644 --- 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 @@ -1,10 +1,11 @@ /* - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * 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; @@ -22,7 +23,7 @@ import java.util.Optional; import jakarta.annotation.Generated; -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.22.0-SNAPSHOT") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") @Validated @RequestMapping("${openapi.openAPIPetstoreGenericsTest.base-path:}") public interface ResultApi { @@ -60,4 +61,21 @@ 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 index e24b34e4dbbd..23f8b2b4e2ca 100644 --- 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 @@ -1,5 +1,5 @@ /* - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * 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. */ @@ -19,7 +19,7 @@ import java.util.Optional; import jakarta.annotation.Generated; -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.22.0-SNAPSHOT") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") @Validated @RequestMapping("${openapi.openAPIPetstoreGenericsTest.base-path:}") public interface SearchApi { 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 index 6e792f03a4d9..6190305873dd 100644 --- 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 @@ -1,5 +1,5 @@ /* - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * 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. */ @@ -19,7 +19,7 @@ import java.util.Optional; import jakarta.annotation.Generated; -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.22.0-SNAPSHOT") +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") @Validated @RequestMapping("${openapi.openAPIPetstoreGenericsTest.base-path:}") public interface VendorApi { 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 index 7dcca9e8f9ac..8628adab707d 100644 --- 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 @@ -22,7 +22,7 @@ * 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.22.0-SNAPSHOT") +@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; 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 index f74943ce4d22..6eef5f913156 100644 --- 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 @@ -19,7 +19,7 @@ * Payload for a log entry */ -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.22.0-SNAPSHOT") +@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; 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 index 740f3e20940d..237ea30ed468 100644 --- 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 @@ -22,7 +22,7 @@ * 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.22.0-SNAPSHOT") +@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; 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 index 3be327776898..21e00be46bb2 100644 --- 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 @@ -19,7 +19,7 @@ * Payload for a metrics entry */ -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.22.0-SNAPSHOT") +@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; 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 index 8e624cc9548a..20a018afb52d 100644 --- 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 @@ -19,7 +19,7 @@ * Order */ -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.22.0-SNAPSHOT") +@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; 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 index 9522ec3aed2b..36ca6c12591f 100644 --- 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 @@ -19,7 +19,7 @@ * Pagination metadata (inline) */ -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.22.0-SNAPSHOT") +@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; 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 index 8708ee04fd35..956dafe2503a 100644 --- 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 @@ -19,7 +19,7 @@ * Payment processing error details */ -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.22.0-SNAPSHOT") +@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; 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 index e62a1ac11ba9..4064d1b081ad 100644 --- 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 @@ -19,7 +19,7 @@ * Pet */ -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.22.0-SNAPSHOT") +@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; 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 index af0b7a14306c..b6c41a5a3f7e 100644 --- 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 @@ -24,7 +24,7 @@ * Search result — unique structure, not matched by any pattern */ -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.22.0-SNAPSHOT") +@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; @@ -33,10 +33,8 @@ public class SearchResult implements Serializable { private @Nullable Long totalHits; - @Valid private List results = new ArrayList<>(); - @Valid private Map facets = new HashMap<>(); public SearchResult query(@Nullable String query) { 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 index 91efc317af2f..5f736fdfa455 100644 --- 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 @@ -19,7 +19,7 @@ * User */ -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.22.0-SNAPSHOT") +@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; 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 index 89a0acaf345f..be159cb34e69 100644 --- 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 @@ -19,7 +19,7 @@ * Validation error details */ -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.22.0-SNAPSHOT") +@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; 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 6de78b9bbd70..a7c0701cbc35 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 @@ -164,7 +164,7 @@ ResponseEntity> findPetsWithExternalParamRefArraySort( produces = { "application/json" } ) ResponseEntity> findPetsWithMinSizeConstraintFromAllOfRef( - @ValidPageable(minSize = 5) final Pageable pageable + @ValidPageable() final Pageable pageable ); From 32e9ca6ef9dd524ea7fbb9909956dfdb675380b6 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Mon, 1 Jun 2026 01:31:46 +0200 Subject: [PATCH 41/43] Fix ArchUnit logger violations on three classes The LOGGERS_SHOULD_BE_NOT_PUBLIC_NOT_STATIC_AND_FINAL ArchUnit rule was violated by static LOGGER fields introduced on this branch: - GenericSubstitutionSupport (instantiable): make LOGGER an instance field - SpringPageableSupport (instantiable): make LOGGER an instance field - GenericSchemaScanUtils (pure static utility): replace static field with a private static logger() helper. ArchUnit only checks fields; slf4j caches logger lookups, so the indirection is essentially free. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../languages/GenericSchemaScanUtils.java | 20 ++++++++++++------- .../languages/GenericSubstitutionSupport.java | 2 +- .../languages/SpringPageableSupport.java | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) 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 index b56796878391..3074a656c2c8 100644 --- 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 @@ -44,7 +44,13 @@ */ public final class GenericSchemaScanUtils { - private static final Logger LOGGER = LoggerFactory.getLogger(GenericSchemaScanUtils.class); + // 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"}; @@ -256,7 +262,7 @@ public static List scanVendorExtensions(OpenAPI openAPI) { } if (typeArgs.isEmpty()) { - LOGGER.warn("GenericSchemaScanUtils: schema '{}' has x-generic-class '{}' but no " + logger().warn("GenericSchemaScanUtils: schema '{}' has x-generic-class '{}' but no " + "x-generic-args — skipping", schemaName, genericClassValue); continue; } @@ -290,7 +296,7 @@ public static List scanVendorExtensions(OpenAPI openAPI) { !isFqn, typeArgs, slotTypeParams, slotProperty, slotIsArray, properties)); - LOGGER.debug("GenericSchemaScanUtils Tier1: schema '{}' → {}{}", + logger().debug("GenericSchemaScanUtils Tier1: schema '{}' → {}{}", schemaName, genericClassName, typeArgs.entrySet().stream() .map(e -> "<" + e.getValue() + ">") @@ -341,7 +347,7 @@ public static List scanWithPatterns(OpenAPI openAPI, for (GenericPatternConfig pattern : patterns) { if (pattern.genericClass == null || pattern.genericClass.isEmpty()) { - LOGGER.warn("GenericSchemaScanUtils Tier2: pattern has no genericClass — skipping: {}", + logger().warn("GenericSchemaScanUtils Tier2: pattern has no genericClass — skipping: {}", pattern); continue; } @@ -360,7 +366,7 @@ public static List scanWithPatterns(OpenAPI openAPI, } if (effectiveSlots == null) { - LOGGER.warn("GenericSchemaScanUtils Tier2: pattern has no slot/slotArray/slots — skipping: {}", + logger().warn("GenericSchemaScanUtils Tier2: pattern has no slot/slotArray/slots — skipping: {}", pattern); continue; } @@ -402,7 +408,7 @@ public static List scanWithPatterns(OpenAPI openAPI, continue; } - LOGGER.debug("GenericSchemaScanUtils Tier2: schema '{}' matched pattern '{}' by name " + logger().debug("GenericSchemaScanUtils Tier2: schema '{}' matched pattern '{}' by name " + "but slot '{}' not found or not a $ref — skipping", schemaName, pattern, slotPropName); allSlotsFound = false; @@ -426,7 +432,7 @@ public static List scanWithPatterns(OpenAPI openAPI, !isFqn, typeArgs, slotTypeParams, primarySlotName, primarySlotIsArray, properties)); - LOGGER.debug("GenericSchemaScanUtils Tier2: schema '{}' matched pattern '{}' → {}<{}>", + 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 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 index 16a2ccfddd76..7b4681cf83a2 100644 --- 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 @@ -85,7 +85,7 @@ */ public final class GenericSubstitutionSupport { - private static final Logger LOGGER = LoggerFactory.getLogger(GenericSubstitutionSupport.class); + private final Logger LOGGER = LoggerFactory.getLogger(GenericSubstitutionSupport.class); // ========================================================================= // Context interface 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 index 15bb28e60f4d..5782f226e7f5 100644 --- 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 @@ -65,7 +65,7 @@ */ public final class SpringPageableSupport { - private static final Logger LOGGER = LoggerFactory.getLogger(SpringPageableSupport.class); + private final Logger LOGGER = LoggerFactory.getLogger(SpringPageableSupport.class); // ------------------------------------------------------------------------- // Context interface — implemented by each generator to expose its internals From 54f2b709a99c7eed630c4126a9575d3184fbb8f8 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Mon, 1 Jun 2026 01:49:30 +0200 Subject: [PATCH 42/43] Fix three regressions surfaced by the failing test suite 1. KotlinSpringServerCodegen: bind useEnumValueInterface from additionalProperties in processOpts(). The CLI switch was registered via addSwitch() but never read back, so the boolean field stayed false and the whole feature was a no-op (no ValuedEnum.kt supporting file, no x-kotlin-implements injection, no override val value). Mirrors the binding already present in SpringCodegen.processOpts(). 2. SpringPageableSupport.processPageableAnnotations(): strip x-spring-paginated for non-spring-boot libraries (spring-cloud, spring-declarative-http-interface) so the template does not render a Pageable parameter for client-side libraries that need explicit page/size/sort query parameters. The original strip logic existed in 772ff0ecfb1 but was lost during the pageable-support refactor. 3. SpringPageableSupport.processPageableAnnotations(): include minSize and minPage in the @ValidPageable annotation attributes. The refactor that moved the annotation builder out of SpringPageableScanUtils.applyPageableAnnotations dropped the two minimum-bound checks, producing @ValidPageable() with no attributes when only a minimum constraint was defined on a paged operation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../languages/KotlinSpringServerCodegen.java | 1 + .../codegen/languages/SpringPageableSupport.java | 13 +++++++++++++ 2 files changed, 14 insertions(+) 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 023078d18475..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 @@ -616,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()); 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 index 5782f226e7f5..eb02433e3897 100644 --- 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 @@ -317,6 +317,17 @@ public void autoDetectPagination(Operation operation, String library) { 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"))) { @@ -340,6 +351,8 @@ public void processPageableAnnotations(CodegenOperation codegenOperation, Contex 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"); } From 7c9a072f9bbb86a5438043e8975702fbdf1dd461 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Mon, 1 Jun 2026 01:55:10 +0200 Subject: [PATCH 43/43] update samples --- .../main/java/org/openapitools/api/PetController.java | 6 ++---- .../src/main/java/org/openapitools/api/PetApi.java | 9 +++------ .../src/main/kotlin/org/openapitools/api/PetApi.kt | 2 +- .../src/main/java/org/openapitools/api/PetApi.java | 2 +- 4 files changed, 7 insertions(+), 12 deletions(-) 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 1e637f43a1a8..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 @@ -125,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 ); @@ -164,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 4949b2dae998..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 @@ -127,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 ); @@ -174,8 +173,7 @@ ResponseEntity> findPetsByTags( @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, - @ParameterObject final Pageable pageable + @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 ); @@ -252,8 +250,7 @@ ResponseEntity getPetById( ResponseEntity> listAllPets( @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, - @ParameterObject final Pageable pageable + @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-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 8a82e645c868..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 @@ -132,7 +132,7 @@ interface PetApi { value = [PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF], produces = ["application/json"] ) - fun findPetsWithMinSizeConstraintFromAllOfRef(@ValidPageable() pageable: Pageable): ResponseEntity> { + fun findPetsWithMinSizeConstraintFromAllOfRef(@ValidPageable(minSize = 5) pageable: Pageable): ResponseEntity> { return ResponseEntity(HttpStatus.NOT_IMPLEMENTED) } 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 a7c0701cbc35..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 @@ -164,7 +164,7 @@ ResponseEntity> findPetsWithExternalParamRefArraySort( produces = { "application/json" } ) ResponseEntity> findPetsWithMinSizeConstraintFromAllOfRef( - @ValidPageable() final Pageable pageable + @ValidPageable(minSize = 5) final Pageable pageable );