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
+ *
+ * Mode A : {@code genericClass} contains a dot ({@code .}) — treated as a
+ * fully-qualified class name. Only an import mapping entry is added; no file is
+ * generated. Use this when the generic class already exists (e.g. a Spring or library
+ * type).
+ * Mode B : {@code genericClass} is a simple name (no dot). A new source file
+ * ({@code .java} or {@code .kt}) is generated in the
+ * {@code configPackage} folder. The generated class has one type parameter {@code T}
+ * and mirrors the non-slot properties of the matched schemas.
+ *
+ */
+public class GenericPatternConfig {
+
+ /**
+ * Schema name suffix to match (e.g. {@code "Response"} matches {@code UserResponse},
+ * {@code PetResponse}, …). Mutually exclusive with {@link #prefix}.
+ */
+ public String suffix;
+
+ /**
+ * Schema name prefix to match (e.g. {@code "Api"} matches {@code ApiUser},
+ * {@code ApiPet}, …). Mutually exclusive with {@link #suffix}.
+ */
+ public String prefix;
+
+ /**
+ * Target generic class name.
+ *
+ *
+ * FQN (contains {@code .}): Mode A — add to importMapping, no file generated.
+ * Simple name (no {@code .}): Mode B — generate class in configPackage.
+ *
+ *
+ * May be {@code null} or empty to skip this entry.
+ */
+ public String genericClass;
+
+ /**
+ * Name of the property that serves as the single {@code $ref} type slot.
+ * The property's referenced schema becomes type argument {@code T}.
+ * Mutually exclusive with {@link #slotArray}.
+ */
+ 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:
+ *
+ * Tier 1 — Vendor extensions : schema carries {@code x-generic-class} and
+ * {@code x-generic-args} extensions (requires spec modification).
+ * Tier 2 — Suffix / prefix patterns : schemas whose name matches a configured
+ * suffix or prefix pattern (requires only generator config, not spec changes).
+ * 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.
+ *
+ *
+ * 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
+ *
+ * Create one instance per generator run and store it as a field.
+ * Call {@link #addPattern} in {@code processOpts()} for each configured pattern.
+ * Call {@link #preprocessOpenAPI} from the generator's {@code preprocessOpenAPI} override.
+ * Call {@link #substituteReturnType} from the generator's {@code fromOperation} override.
+ * Call {@link #suppressGenericSchemas} from {@code postProcessAllModels}.
+ *
+ *
+ * Mode A vs Mode B
+ *
+ * Mode A ({@code genericClass} is a FQN): only an import-mapping entry is
+ * added; no source file is generated.
+ * Mode B ({@code genericClass} is a simple name): a {@code .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