From cea9861b8b2391715697954623febb15e6363f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1chym=20Metli=C4=8Dka?= Date: Tue, 5 May 2026 16:52:35 +0200 Subject: [PATCH 01/21] Enhance pageable validation by adding minSize and minPage constraints. Search also allOf references for constraints and defaults --- .../languages/KotlinSpringServerCodegen.java | 2 + .../codegen/languages/SpringCodegen.java | 2 + .../languages/SpringPageableScanUtils.java | 127 +++++++++++++++--- .../JavaSpring/validPageable.mustache | 36 ++++- .../kotlin-spring/validPageable.mustache | 32 ++++- .../java/spring/SpringCodegenTest.java | 59 ++++++++ .../spring/KotlinSpringServerCodegenTest.java | 60 ++++++++- .../3_0/spring/petstore-sort-validation.yaml | 102 ++++++++++++++ 8 files changed, 391 insertions(+), 29 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index 6d2a86061991..8f41b31c7c69 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java @@ -1100,6 +1100,8 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation List attrs = new ArrayList<>(); if (constraints.maxSize >= 0) attrs.add("maxSize = " + constraints.maxSize); if (constraints.maxPage >= 0) attrs.add("maxPage = " + constraints.maxPage); + if (constraints.minSize >= 0) attrs.add("minSize = " + constraints.minSize); + if (constraints.minPage >= 0) attrs.add("minPage = " + constraints.minPage); pageableAnnotations.add("@ValidPageable(" + String.join(", ", attrs) + ")"); codegenOperation.imports.add("ValidPageable"); } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java index 76e20d09be36..7eb9dc53f82c 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java @@ -1274,6 +1274,8 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation List attrs = new ArrayList<>(); if (constraints.maxSize >= 0) attrs.add("maxSize = " + constraints.maxSize); if (constraints.maxPage >= 0) attrs.add("maxPage = " + constraints.maxPage); + if (constraints.minSize >= 0) attrs.add("minSize = " + constraints.minSize); + if (constraints.minPage >= 0) attrs.add("minPage = " + constraints.minPage); pageableAnnotations.add("@ValidPageable(" + String.join(", ", attrs) + ")"); codegenOperation.imports.add("ValidPageable"); } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java index 17ceb3757fdb..b91c6b0e435b 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java @@ -24,6 +24,7 @@ import io.swagger.v3.oas.models.parameters.Parameter; import org.openapitools.codegen.utils.ModelUtils; +import java.math.BigDecimal; import java.util.*; import java.util.stream.Collectors; @@ -71,22 +72,28 @@ public boolean hasAny() { } /** - * Carries max constraints for page number and page size from a pageable operation. - * {@code -1} means no constraint specified (no {@code maximum:} in the spec). + * Carries max and min constraints for page number and page size from a pageable operation. + * {@code -1} means no constraint specified (no {@code maximum:}/{@code minimum:} in the spec). */ public static final class PageableConstraintsData { /** Maximum allowed page number, or {@code -1} if unconstrained. */ public final int maxPage; /** Maximum allowed page size, or {@code -1} if unconstrained. */ public final int maxSize; + /** Minimum allowed page number, or {@code -1} if unconstrained. */ + public final int minPage; + /** Minimum allowed page size, or {@code -1} if unconstrained. */ + public final int minSize; - public PageableConstraintsData(int maxPage, int maxSize) { + public PageableConstraintsData(int maxPage, int maxSize, int minPage, int minSize) { this.maxPage = maxPage; this.maxSize = maxSize; + this.minPage = minPage; + this.minSize = minSize; } public boolean hasAny() { - return maxPage >= 0 || maxSize >= 0; + return maxPage >= 0 || maxSize >= 0 || minPage >= 0 || minSize >= 0; } } @@ -205,13 +212,10 @@ public static Map scanPageableDefaults( if (schema == null) { continue; } - if (schema.get$ref() != null) { - schema = ModelUtils.getReferencedSchema(openAPI, schema); - } - if (schema == null || schema.getDefault() == null) { + Object defaultValue = resolveDefault(openAPI, schema); + if (defaultValue == null) { continue; } - Object defaultValue = schema.getDefault(); switch (param.getName()) { case "page": if (defaultValue instanceof Number) { @@ -256,11 +260,12 @@ public static Map scanPageableDefaults( } /** - * Scans all pageable operations for {@code maximum:} constraints on {@code page} and - * {@code size} parameters. + * Scans all pageable operations for {@code maximum:} and {@code minimum:} constraints on + * {@code page} and {@code size} parameters. Values are resolved through {@code allOf} and + * {@code $ref} schemas so that constraints defined on shared component schemas are honoured. * * @return map from operationId to {@link PageableConstraintsData} (only operations with - * at least one {@code maximum:} constraint are included) + * at least one constraint are included) */ public static Map scanPageableConstraints( OpenAPI openAPI, boolean autoXSpringPaginated) { @@ -279,30 +284,29 @@ public static Map scanPageableConstraints( } int maxPage = -1; int maxSize = -1; + int minPage = -1; + int minSize = -1; for (Parameter param : operation.getParameters()) { Schema schema = param.getSchema(); if (schema == null) { continue; } - if (schema.get$ref() != null) { - schema = ModelUtils.getReferencedSchema(openAPI, schema); - } - if (schema == null || schema.getMaximum() == null) { - continue; - } - int maximum = schema.getMaximum().intValue(); + BigDecimal maximum = resolveMaximum(openAPI, schema); + BigDecimal minimum = resolveMinimum(openAPI, schema); switch (param.getName()) { case "page": - maxPage = maximum; + if (maximum != null) maxPage = maximum.intValue(); + if (minimum != null) minPage = minimum.intValue(); break; case "size": - maxSize = maximum; + if (maximum != null) maxSize = maximum.intValue(); + if (minimum != null) minSize = minimum.intValue(); break; default: break; } } - PageableConstraintsData data = new PageableConstraintsData(maxPage, maxSize); + PageableConstraintsData data = new PageableConstraintsData(maxPage, maxSize, minPage, minSize); if (data.hasAny()) { result.put(operationId, data); } @@ -310,4 +314,83 @@ public static Map scanPageableConstraints( } return result; } + + // ------------------------------------------------------------------------- + // Private schema-resolution helpers + // ------------------------------------------------------------------------- + + /** + * Returns the effective {@code maximum} for the given schema, resolving through a top-level + * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive + * (smallest) value wins. + */ + private static BigDecimal resolveMaximum(OpenAPI openAPI, Schema schema) { + if (schema == null) return null; + if (schema.get$ref() != null) { + schema = ModelUtils.getReferencedSchema(openAPI, schema); + if (schema == null) return null; + } + BigDecimal result = schema.getMaximum(); + if (schema.getAllOf() != null) { + for (Schema allOfItem : schema.getAllOf()) { + Schema resolved = ModelUtils.getReferencedSchema(openAPI, allOfItem); + if (resolved != null && resolved.getMaximum() != null) { + if (result == null || resolved.getMaximum().compareTo(result) < 0) { + result = resolved.getMaximum(); + } + } + } + } + return result; + } + + /** + * Returns the effective {@code minimum} for the given schema, resolving through a top-level + * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive + * (largest) value wins. + */ + private static BigDecimal resolveMinimum(OpenAPI openAPI, Schema schema) { + if (schema == null) return null; + if (schema.get$ref() != null) { + schema = ModelUtils.getReferencedSchema(openAPI, schema); + if (schema == null) return null; + } + BigDecimal result = schema.getMinimum(); + if (schema.getAllOf() != null) { + for (Schema allOfItem : schema.getAllOf()) { + Schema resolved = ModelUtils.getReferencedSchema(openAPI, allOfItem); + if (resolved != null && resolved.getMinimum() != null) { + if (result == null || resolved.getMinimum().compareTo(result) > 0) { + result = resolved.getMinimum(); + } + } + } + } + return result; + } + + /** + * Returns the effective {@code default} for the given schema. Unlike constraints, the inline + * schema's default takes precedence (explicit per-endpoint override); falls back to the first + * non-null default found in {@code allOf} items. + */ + private static Object resolveDefault(OpenAPI openAPI, Schema schema) { + if (schema == null) return null; + if (schema.get$ref() != null) { + schema = ModelUtils.getReferencedSchema(openAPI, schema); + if (schema == null) return null; + } + if (schema.getDefault() != null) return schema.getDefault(); + if (schema.getAllOf() != null) { + for (Schema allOfItem : schema.getAllOf()) { + Schema resolved = ModelUtils.getReferencedSchema(openAPI, allOfItem); + if (resolved != null && resolved.getDefault() != null) { + return resolved.getDefault(); + } + } + } + return null; + } } diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/validPageable.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/validPageable.mustache index daf547481640..7cb93f4b6bf6 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/validPageable.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/validPageable.mustache @@ -13,13 +13,15 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Validates that the page number and page size in the annotated {@link Pageable} parameter do not - * exceed their configured maximums. + * Validates that the page number and page size in the annotated {@link Pageable} parameter are + * within their configured bounds. * *

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

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

Use {@link #NO_LIMIT} (= {@code -1}, the default) to leave an attribute unconstrained. @@ -43,6 +45,12 @@ public @interface ValidPageable { /** Maximum allowed page number (0-based), or {@link #NO_LIMIT} if unconstrained. */ int maxPage() default NO_LIMIT; + /** Minimum allowed page size, or {@link #NO_LIMIT} if unconstrained. */ + int minSize() default NO_LIMIT; + + /** Minimum allowed page number (0-based), or {@link #NO_LIMIT} if unconstrained. */ + int minPage() default NO_LIMIT; + Class[] groups() default {}; Class[] payload() default {}; @@ -53,11 +61,15 @@ public @interface ValidPageable { private int maxSize = NO_LIMIT; private int maxPage = NO_LIMIT; + private int minSize = NO_LIMIT; + private int minPage = NO_LIMIT; @Override public void initialize(ValidPageable constraintAnnotation) { maxSize = constraintAnnotation.maxSize(); maxPage = constraintAnnotation.maxPage(); + minSize = constraintAnnotation.minSize(); + minPage = constraintAnnotation.minPage(); } @Override @@ -93,6 +105,26 @@ public @interface ValidPageable { valid = false; } + if (minSize >= 0 && pageable.getPageSize() < minSize) { + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate() + + ": page size " + pageable.getPageSize() + + " is below minimum " + minSize) + .addPropertyNode("size") + .addConstraintViolation(); + valid = false; + } + + if (minPage >= 0 && pageable.getPageNumber() < minPage) { + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate() + + ": page number " + pageable.getPageNumber() + + " is below minimum " + minPage) + .addPropertyNode("page") + .addConstraintViolation(); + valid = false; + } + return valid; } } diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/validPageable.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/validPageable.mustache index 6b26b7a26803..c87a9da537cb 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/validPageable.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/validPageable.mustache @@ -7,12 +7,14 @@ import {{javaxPackage}}.validation.Payload import org.springframework.data.domain.Pageable /** - * Validates that the page number and page size in the annotated [Pageable] parameter do not - * exceed their configured maximums. + * Validates that the page number and page size in the annotated [Pageable] parameter are within + * their configured bounds. * * Apply directly on a `pageable: Pageable` parameter. Each attribute is independently optional: * - [maxSize] — when set (>= 0), validates `pageable.pageSize <= maxSize` * - [maxPage] — when set (>= 0), validates `pageable.pageNumber <= maxPage` + * - [minSize] — when set (>= 0), validates `pageable.pageSize >= minSize` + * - [minPage] — when set (>= 0), validates `pageable.pageNumber >= minPage` * * Use [NO_LIMIT] (= -1, the default) to leave an attribute unconstrained. * @@ -21,6 +23,8 @@ import org.springframework.data.domain.Pageable * * @property maxSize Maximum allowed page size, or [NO_LIMIT] if unconstrained * @property maxPage Maximum allowed page number (0-based), or [NO_LIMIT] if unconstrained + * @property minSize Minimum allowed page size, or [NO_LIMIT] if unconstrained + * @property minPage Minimum allowed page number (0-based), or [NO_LIMIT] if unconstrained * @property groups Validation groups (optional) * @property payload Additional payload (optional) * @property message Validation error message (default: "Invalid page request") @@ -32,6 +36,8 @@ import org.springframework.data.domain.Pageable annotation class ValidPageable( val maxSize: Int = ValidPageable.NO_LIMIT, val maxPage: Int = ValidPageable.NO_LIMIT, + val minSize: Int = ValidPageable.NO_LIMIT, + val minPage: Int = ValidPageable.NO_LIMIT, val groups: Array> = [], val payload: Array> = [], val message: String = "Invalid page request" @@ -45,10 +51,14 @@ class PageableConstraintValidator : ConstraintValidator private var maxSize = ValidPageable.NO_LIMIT private var maxPage = ValidPageable.NO_LIMIT + private var minSize = ValidPageable.NO_LIMIT + private var minPage = ValidPageable.NO_LIMIT override fun initialize(constraintAnnotation: ValidPageable) { maxSize = constraintAnnotation.maxSize maxPage = constraintAnnotation.maxPage + minSize = constraintAnnotation.minSize + minPage = constraintAnnotation.minPage } override fun isValid(pageable: Pageable?, context: ConstraintValidatorContext): Boolean { @@ -76,6 +86,24 @@ class PageableConstraintValidator : ConstraintValidator valid = false } + if (minSize >= 0 && pageable.pageSize < minSize) { + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate}: page size ${pageable.pageSize} is below minimum $minSize" + ) + .addPropertyNode("size") + .addConstraintViolation() + valid = false + } + + if (minPage >= 0 && pageable.pageNumber < minPage) { + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate}: page number ${pageable.pageNumber} is below minimum $minPage" + ) + .addPropertyNode("page") + .addConstraintViolation() + valid = false + } + return valid } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index b5d0467b75fa..1f74191e9bef 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java @@ -7147,6 +7147,65 @@ public void generatePageableConstraintValidationWithBothConstraints() throws IOE .containsWithNameAndAttributes("ValidPageable", Map.of("maxSize", "50", "maxPage", "999")); } + @Test + public void generatePageableConstraintValidationResolvesMaximumFromAllOfRef() throws IOException { + Map props = new HashMap<>(); + props.put(SpringCodegen.INTERFACE_ONLY, "true"); + props.put(SpringCodegen.SKIP_DEFAULT_INTERFACE, "true"); + props.put(SpringCodegen.USE_TAGS, "true"); + props.put(SpringCodegen.USE_SPRING_BOOT3, "true"); + props.put(SpringCodegen.GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-sort-validation.yaml", SPRING_BOOT, props); + + // findPetsWithSizeConstraintFromAllOfRef: maximum: 75 is on the referenced schema only + JavaFileAssert.assertThat(files.get("PetApi.java")) + .assertMethod("findPetsWithSizeConstraintFromAllOfRef") + .assertParameter("pageable") + .assertParameterAnnotations() + .containsWithNameAndAttributes("ValidPageable", Map.of("maxSize", "75")); + } + + @Test + public void generatePageableConstraintValidationResolvesMinimumFromAllOfRef() throws IOException { + Map props = new HashMap<>(); + props.put(SpringCodegen.INTERFACE_ONLY, "true"); + props.put(SpringCodegen.SKIP_DEFAULT_INTERFACE, "true"); + props.put(SpringCodegen.USE_TAGS, "true"); + props.put(SpringCodegen.USE_SPRING_BOOT3, "true"); + props.put(SpringCodegen.GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-sort-validation.yaml", SPRING_BOOT, props); + + // findPetsWithMinSizeConstraintFromAllOfRef: minimum: 5 is on the referenced schema only + JavaFileAssert.assertThat(files.get("PetApi.java")) + .assertMethod("findPetsWithMinSizeConstraintFromAllOfRef") + .assertParameter("pageable") + .assertParameterAnnotations() + .containsWithNameAndAttributes("ValidPageable", Map.of("minSize", "5")); + } + + @Test + public void scanPageableDefaultsResolvesDefaultFromAllOfRef() throws IOException { + Map props = new HashMap<>(); + props.put(SpringCodegen.INTERFACE_ONLY, "true"); + props.put(SpringCodegen.SKIP_DEFAULT_INTERFACE, "true"); + props.put(SpringCodegen.USE_TAGS, "true"); + props.put(SpringCodegen.USE_SPRING_BOOT3, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-sort-validation.yaml", SPRING_BOOT, props); + + // findPetsWithDefaultFromAllOfRef: default: 7 is on the referenced schema only + JavaFileAssert.assertThat(files.get("PetApi.java")) + .assertMethod("findPetsWithDefaultFromAllOfRef") + .assertParameter("pageable") + .assertParameterAnnotations() + .containsWithNameAndAttributes("PageableDefault", Map.of("size", "7")); + } + // ------------------------------------------------------------------------- // @PageableDefault / @SortDefault tests // ------------------------------------------------------------------------- diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index 95c8024deccc..0d3795b75cd5 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -4231,6 +4231,8 @@ public void generatePageableConstraintValidationGeneratesValidPageableFile() thr assertFileContains(validPageableFile.toPath(), "class PageableConstraintValidator"); assertFileContains(validPageableFile.toPath(), "val maxSize: Int"); assertFileContains(validPageableFile.toPath(), "val maxPage: Int"); + assertFileContains(validPageableFile.toPath(), "val minSize: Int"); + assertFileContains(validPageableFile.toPath(), "val minPage: Int"); assertFileContains(validPageableFile.toPath(), "NO_LIMIT"); } @@ -4267,15 +4269,67 @@ public void generatePageableConstraintValidationDoesNotGenerateFileWhenBeanValid // ========== AUTO X-SPRING-PAGINATED TESTS ========== - // ========== GENERATE SORT VALIDATION TESTS ========== + @Test + public void generatePageableConstraintValidationResolvesMaximumFromAllOfRef() throws Exception { + Map additionalProperties = new HashMap<>(); + additionalProperties.put(USE_TAGS, "true"); + additionalProperties.put(INTERFACE_ONLY, "true"); + additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); + additionalProperties.put(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "true"); + + Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); + + File petApi = files.get("PetApi.kt"); + String content = Files.readString(petApi.toPath()); + + // findPetsWithSizeConstraintFromAllOfRef: maximum: 75 is on the referenced schema only + int methodStart = content.indexOf("fun findPetsWithSizeConstraintFromAllOfRef("); + Assert.assertTrue(methodStart >= 0, "findPetsWithSizeConstraintFromAllOfRef method should exist"); + String paramBlock = content.substring(methodStart, Math.min(content.length(), methodStart + 500)); + Assert.assertTrue(paramBlock.contains("@ValidPageable(maxSize = 75)"), + "@ValidPageable(maxSize = 75) should be resolved from allOf $ref schema"); + } @Test - public void generateSortValidationAddsAnnotationForExplicitPaginated() throws Exception { + public void generatePageableConstraintValidationResolvesMinimumFromAllOfRef() throws Exception { Map additionalProperties = new HashMap<>(); additionalProperties.put(USE_TAGS, "true"); additionalProperties.put(INTERFACE_ONLY, "true"); additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); - additionalProperties.put(GENERATE_SORT_VALIDATION, "true"); + additionalProperties.put(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "true"); + + Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); + + File petApi = files.get("PetApi.kt"); + String content = Files.readString(petApi.toPath()); + + // findPetsWithMinSizeConstraintFromAllOfRef: minimum: 5 is on the referenced schema only + int methodStart = content.indexOf("fun findPetsWithMinSizeConstraintFromAllOfRef("); + Assert.assertTrue(methodStart >= 0, "findPetsWithMinSizeConstraintFromAllOfRef method should exist"); + String paramBlock = content.substring(methodStart, Math.min(content.length(), methodStart + 500)); + Assert.assertTrue(paramBlock.contains("@ValidPageable(minSize = 5)"), + "@ValidPageable(minSize = 5) should be resolved from allOf $ref schema"); + } + + @Test + public void scanPageableDefaultsResolvesDefaultFromAllOfRef() throws Exception { + Map additionalProperties = new HashMap<>(); + additionalProperties.put(USE_TAGS, "true"); + additionalProperties.put(INTERFACE_ONLY, "true"); + additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); + + Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); + + File petApi = files.get("PetApi.kt"); + // findPetsWithDefaultFromAllOfRef: default: 7 is on the referenced schema only + assertFileContains(petApi.toPath(), "@PageableDefault(size = 7)"); + } + + // ========== AUTO X-SPRING-PAGINATED TESTS ========== + + @Test + public void generateSortValidationAddsAnnotationForExplicitPaginated() throws Exception { + Map additionalProperties = new HashMap<>(); Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); diff --git a/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml index 86d398d2c407..e6368583e71a 100644 --- a/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml @@ -540,6 +540,96 @@ paths: type: array items: $ref: '#/components/schemas/Pet' + /pet/findWithSizeConstraintFromAllOfRef: + get: + tags: + - pet + summary: Find pets — size maximum resolved from allOf $ref (no inline maximum) + operationId: findPetsWithSizeConstraintFromAllOfRef + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + - name: size + in: query + schema: + allOf: + - $ref: '#/components/schemas/PageSizeWithMax' + - name: sort + in: query + schema: + type: string + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /pet/findWithDefaultFromAllOfRef: + get: + tags: + - pet + summary: Find pets — size default resolved from allOf $ref (no inline default) + operationId: findPetsWithDefaultFromAllOfRef + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + - name: size + in: query + schema: + allOf: + - $ref: '#/components/schemas/PageSizeWithDefault' + - name: sort + in: query + schema: + type: string + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /pet/findWithMinSizeConstraintFromAllOfRef: + get: + tags: + - pet + summary: Find pets — size minimum resolved from allOf $ref (no inline minimum) + operationId: findPetsWithMinSizeConstraintFromAllOfRef + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + - name: size + in: query + schema: + allOf: + - $ref: '#/components/schemas/PageSizeWithMin' + - name: sort + in: query + schema: + type: string + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' components: schemas: PetSort: @@ -549,6 +639,18 @@ components: - "id,desc" - "createdAt,asc" - "createdAt,desc" + PageSizeWithMax: + type: integer + format: int32 + maximum: 75 + PageSizeWithDefault: + type: integer + format: int32 + default: 7 + PageSizeWithMin: + type: integer + format: int32 + minimum: 5 Pet: type: object required: From 2b24608837c88baf40e132fe0ed1440b598f6d62 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 5 May 2026 23:41:50 +0200 Subject: [PATCH 02/21] update samples --- .../kotlin/org/openapitools/api/PetApi.kt | 36 +++++ .../configuration/ValidPageable.kt | 32 +++- .../java/org/openapitools/api/PetApi.java | 48 ++++++ .../configuration/ValidPageable.java | 36 ++++- .../src/main/resources/openapi.yaml | 141 ++++++++++++++++++ 5 files changed, 289 insertions(+), 4 deletions(-) diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApi.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApi.kt index 23cd662f699c..98e5aa03d13e 100644 --- a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApi.kt +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApi.kt @@ -104,6 +104,17 @@ interface PetApi { } + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithDefaultFromAllOfRef" + value = [PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF], + produces = ["application/json"] + ) + fun findPetsWithDefaultFromAllOfRef(@PageableDefault(size = 7) pageable: Pageable): ResponseEntity> { + return ResponseEntity(HttpStatus.NOT_IMPLEMENTED) + } + + @RequestMapping( method = [RequestMethod.GET], // "/pet/findWithExternalParamRefArraySort" @@ -115,6 +126,17 @@ interface PetApi { } + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithMinSizeConstraintFromAllOfRef" + value = [PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF], + produces = ["application/json"] + ) + fun findPetsWithMinSizeConstraintFromAllOfRef(@ValidPageable(minSize = 5) pageable: Pageable): ResponseEntity> { + return ResponseEntity(HttpStatus.NOT_IMPLEMENTED) + } + + @RequestMapping( method = [RequestMethod.GET], // "/pet/findWithMixedSortDefaults" @@ -181,6 +203,17 @@ interface PetApi { } + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithSizeConstraintFromAllOfRef" + value = [PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF], + produces = ["application/json"] + ) + fun findPetsWithSizeConstraintFromAllOfRef(@ValidPageable(maxSize = 75) pageable: Pageable): ResponseEntity> { + return ResponseEntity(HttpStatus.NOT_IMPLEMENTED) + } + + @RequestMapping( method = [RequestMethod.GET], // "/pet/findWithSortDefaultAsc" @@ -235,13 +268,16 @@ interface PetApi { const val PATH_FIND_PETS_WITH_ALL_DEFAULTS: String = "/pet/findWithAllDefaults" const val PATH_FIND_PETS_WITH_ARRAY_SORT_ENUM: String = "/pet/findWithArraySortEnum" const val PATH_FIND_PETS_WITH_ARRAY_SORT_REF_ENUM: String = "/pet/findWithArraySortRefEnum" + const val PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF: String = "/pet/findWithDefaultFromAllOfRef" const val PATH_FIND_PETS_WITH_EXTERNAL_PARAM_REF_ARRAY_SORT: String = "/pet/findWithExternalParamRefArraySort" + const val PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF: String = "/pet/findWithMinSizeConstraintFromAllOfRef" const val PATH_FIND_PETS_WITH_MIXED_SORT_DEFAULTS: String = "/pet/findWithMixedSortDefaults" const val PATH_FIND_PETS_WITH_NON_EXPLODED_EXTERNAL_PARAM_REF_ARRAY_SORT: String = "/pet/findWithNonExplodedExternalParamRefArraySort" const val PATH_FIND_PETS_WITH_PAGE_AND_SIZE_CONSTRAINT: String = "/pet/findWithPageAndSizeConstraint" const val PATH_FIND_PETS_WITH_PAGE_SIZE_DEFAULTS_ONLY: String = "/pet/findWithPageSizeDefaultsOnly" const val PATH_FIND_PETS_WITH_REF_SORT: String = "/pet/findWithRefSort" const val PATH_FIND_PETS_WITH_SIZE_CONSTRAINT: String = "/pet/findWithSizeConstraint" + const val PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF: String = "/pet/findWithSizeConstraintFromAllOfRef" const val PATH_FIND_PETS_WITH_SORT_DEFAULT_ASC: String = "/pet/findWithSortDefaultAsc" const val PATH_FIND_PETS_WITH_SORT_DEFAULT_ONLY: String = "/pet/findWithSortDefaultOnly" const val PATH_FIND_PETS_WITH_SORT_ENUM: String = "/pet/findByStatusWithSort" diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidPageable.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidPageable.kt index 671e682ec6fe..095e3ba8fac5 100644 --- a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidPageable.kt +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidPageable.kt @@ -7,12 +7,14 @@ import jakarta.validation.Payload import org.springframework.data.domain.Pageable /** - * Validates that the page number and page size in the annotated [Pageable] parameter do not - * exceed their configured maximums. + * Validates that the page number and page size in the annotated [Pageable] parameter are within + * their configured bounds. * * Apply directly on a `pageable: Pageable` parameter. Each attribute is independently optional: * - [maxSize] — when set (>= 0), validates `pageable.pageSize <= maxSize` * - [maxPage] — when set (>= 0), validates `pageable.pageNumber <= maxPage` + * - [minSize] — when set (>= 0), validates `pageable.pageSize >= minSize` + * - [minPage] — when set (>= 0), validates `pageable.pageNumber >= minPage` * * Use [NO_LIMIT] (= -1, the default) to leave an attribute unconstrained. * @@ -21,6 +23,8 @@ import org.springframework.data.domain.Pageable * * @property maxSize Maximum allowed page size, or [NO_LIMIT] if unconstrained * @property maxPage Maximum allowed page number (0-based), or [NO_LIMIT] if unconstrained + * @property minSize Minimum allowed page size, or [NO_LIMIT] if unconstrained + * @property minPage Minimum allowed page number (0-based), or [NO_LIMIT] if unconstrained * @property groups Validation groups (optional) * @property payload Additional payload (optional) * @property message Validation error message (default: "Invalid page request") @@ -32,6 +36,8 @@ import org.springframework.data.domain.Pageable annotation class ValidPageable( val maxSize: Int = ValidPageable.NO_LIMIT, val maxPage: Int = ValidPageable.NO_LIMIT, + val minSize: Int = ValidPageable.NO_LIMIT, + val minPage: Int = ValidPageable.NO_LIMIT, val groups: Array> = [], val payload: Array> = [], val message: String = "Invalid page request" @@ -45,10 +51,14 @@ class PageableConstraintValidator : ConstraintValidator private var maxSize = ValidPageable.NO_LIMIT private var maxPage = ValidPageable.NO_LIMIT + private var minSize = ValidPageable.NO_LIMIT + private var minPage = ValidPageable.NO_LIMIT override fun initialize(constraintAnnotation: ValidPageable) { maxSize = constraintAnnotation.maxSize maxPage = constraintAnnotation.maxPage + minSize = constraintAnnotation.minSize + minPage = constraintAnnotation.minPage } override fun isValid(pageable: Pageable?, context: ConstraintValidatorContext): Boolean { @@ -76,6 +86,24 @@ class PageableConstraintValidator : ConstraintValidator valid = false } + if (minSize >= 0 && pageable.pageSize < minSize) { + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate}: page size ${pageable.pageSize} is below minimum $minSize" + ) + .addPropertyNode("size") + .addConstraintViolation() + valid = false + } + + if (minPage >= 0 && pageable.pageNumber < minPage) { + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate}: page number ${pageable.pageNumber} is below minimum $minPage" + ) + .addPropertyNode("page") + .addConstraintViolation() + valid = false + } + return valid } } diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApi.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApi.java index df6af79e77d0..57841e79adac 100644 --- a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApi.java +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApi.java @@ -120,6 +120,22 @@ ResponseEntity> findPetsWithArraySortRefEnum( ); + String PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF = "/pet/findWithDefaultFromAllOfRef"; + /** + * GET /pet/findWithDefaultFromAllOfRef : Find pets — size default resolved from allOf $ref (no inline default) + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF, + produces = { "application/json" } + ) + ResponseEntity> findPetsWithDefaultFromAllOfRef( + @PageableDefault(size = 7) final Pageable pageable + ); + + String PATH_FIND_PETS_WITH_EXTERNAL_PARAM_REF_ARRAY_SORT = "/pet/findWithExternalParamRefArraySort"; /** * GET /pet/findWithExternalParamRefArraySort : Find pets with x-spring-paginated and sort param referenced from an external components file @@ -136,6 +152,22 @@ ResponseEntity> findPetsWithExternalParamRefArraySort( ); + String PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF = "/pet/findWithMinSizeConstraintFromAllOfRef"; + /** + * GET /pet/findWithMinSizeConstraintFromAllOfRef : Find pets — size minimum resolved from allOf $ref (no inline minimum) + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF, + produces = { "application/json" } + ) + ResponseEntity> findPetsWithMinSizeConstraintFromAllOfRef( + @ValidPageable(minSize = 5) final Pageable pageable + ); + + String PATH_FIND_PETS_WITH_MIXED_SORT_DEFAULTS = "/pet/findWithMixedSortDefaults"; /** * GET /pet/findWithMixedSortDefaults : Find pets — multiple sort defaults with mixed directions (array sort param) @@ -232,6 +264,22 @@ ResponseEntity> findPetsWithSizeConstraint( ); + String PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF = "/pet/findWithSizeConstraintFromAllOfRef"; + /** + * GET /pet/findWithSizeConstraintFromAllOfRef : Find pets — size maximum resolved from allOf $ref (no inline maximum) + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF, + produces = { "application/json" } + ) + ResponseEntity> findPetsWithSizeConstraintFromAllOfRef( + @ValidPageable(maxSize = 75) final Pageable pageable + ); + + String PATH_FIND_PETS_WITH_SORT_DEFAULT_ASC = "/pet/findWithSortDefaultAsc"; /** * GET /pet/findWithSortDefaultAsc : Find pets — sort default only (single field, no explicit direction defaults to ASC) diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidPageable.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidPageable.java index 04b2ce26a5fc..42995b27d115 100644 --- a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidPageable.java +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidPageable.java @@ -13,13 +13,15 @@ import java.lang.annotation.Target; /** - * Validates that the page number and page size in the annotated {@link Pageable} parameter do not - * exceed their configured maximums. + * Validates that the page number and page size in the annotated {@link Pageable} parameter are + * within their configured bounds. * *

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

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

Use {@link #NO_LIMIT} (= {@code -1}, the default) to leave an attribute unconstrained. @@ -43,6 +45,12 @@ /** Maximum allowed page number (0-based), or {@link #NO_LIMIT} if unconstrained. */ int maxPage() default NO_LIMIT; + /** Minimum allowed page size, or {@link #NO_LIMIT} if unconstrained. */ + int minSize() default NO_LIMIT; + + /** Minimum allowed page number (0-based), or {@link #NO_LIMIT} if unconstrained. */ + int minPage() default NO_LIMIT; + Class[] groups() default {}; Class[] payload() default {}; @@ -53,11 +61,15 @@ class PageableConstraintValidator implements ConstraintValidator= 0 && pageable.getPageSize() < minSize) { + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate() + + ": page size " + pageable.getPageSize() + + " is below minimum " + minSize) + .addPropertyNode("size") + .addConstraintViolation(); + valid = false; + } + + if (minPage >= 0 && pageable.getPageNumber() < minPage) { + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate() + + ": page number " + pageable.getPageNumber() + + " is below minimum " + minPage) + .addPropertyNode("page") + .addConstraintViolation(); + valid = false; + } + return valid; } } diff --git a/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml b/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml index a656feda6c06..6b4efe9f886b 100644 --- a/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml +++ b/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml @@ -757,6 +757,135 @@ paths: - application/json x-tags: - tag: pet + /pet/findWithSizeConstraintFromAllOfRef: + get: + operationId: findPetsWithSizeConstraintFromAllOfRef + parameters: + - explode: true + in: query + name: page + required: false + schema: + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + allOf: + - $ref: "#/components/schemas/PageSizeWithMax" + style: form + - explode: true + in: query + name: sort + required: false + schema: + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets — size maximum resolved from allOf $ref (no inline maximum) + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findWithDefaultFromAllOfRef: + get: + operationId: findPetsWithDefaultFromAllOfRef + parameters: + - explode: true + in: query + name: page + required: false + schema: + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + allOf: + - $ref: "#/components/schemas/PageSizeWithDefault" + style: form + - explode: true + in: query + name: sort + required: false + schema: + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets — size default resolved from allOf $ref (no inline default) + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findWithMinSizeConstraintFromAllOfRef: + get: + operationId: findPetsWithMinSizeConstraintFromAllOfRef + parameters: + - explode: true + in: query + name: page + required: false + schema: + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + allOf: + - $ref: "#/components/schemas/PageSizeWithMin" + style: form + - explode: true + in: query + name: sort + required: false + schema: + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets — size minimum resolved from allOf $ref (no inline minimum) + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet components: parameters: PetSortParam: @@ -790,6 +919,18 @@ components: - "createdAt,asc" - "createdAt,desc" type: string + PageSizeWithMax: + format: int32 + maximum: 75 + type: integer + PageSizeWithDefault: + default: 7 + format: int32 + type: integer + PageSizeWithMin: + format: int32 + minimum: 5 + type: integer Pet: example: name: name From 1e4013ae8c8430550564119e2233e620360c7b5f Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 5 May 2026 23:49:32 +0200 Subject: [PATCH 03/21] fix test --- .../codegen/kotlin/spring/KotlinSpringServerCodegenTest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index 0d3795b75cd5..4606fef643cb 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -4330,6 +4330,10 @@ public void scanPageableDefaultsResolvesDefaultFromAllOfRef() throws Exception { @Test public void generateSortValidationAddsAnnotationForExplicitPaginated() throws Exception { Map additionalProperties = new HashMap<>(); + additionalProperties.put(USE_TAGS, "true"); + additionalProperties.put(INTERFACE_ONLY, "true"); + additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); + additionalProperties.put(GENERATE_SORT_VALIDATION, "true"); Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); From 0701d5b65a80014578db406146cb9edbfbf11522 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 6 May 2026 00:07:56 +0200 Subject: [PATCH 04/21] use ModelUtils in SpringPageableScanUtils --- .../openapitools/codegen/languages/SpringPageableScanUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java index b91c6b0e435b..74ae7ae65669 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java @@ -162,7 +162,7 @@ public static Map> scanSortValidationEnums( } // If the top-level schema is an array, the enum lives on its items Schema enumSchema = schema; - if (schema.getItems() != null) { + if (ModelUtils.isArraySchema(schema)) { enumSchema = schema.getItems(); if (enumSchema.get$ref() != null) { enumSchema = ModelUtils.getReferencedSchema(openAPI, enumSchema); From b463d88e254fac279e8131d8dedee41cf430cf64 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 6 May 2026 09:42:17 +0200 Subject: [PATCH 05/21] update tests in samples --- .../openapitools/api/PetApiController.java | 21 ++++++ .../api/PetApiValidationTest.java | 66 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApiController.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApiController.java index bef1f3a47ab0..12b29649db49 100644 --- a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApiController.java +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApiController.java @@ -90,6 +90,16 @@ public ResponseEntity> findPetsWithPageAndSizeConstraint(Pageable page return ResponseEntity.ok(Collections.emptyList()); } + @Override + public ResponseEntity> findPetsWithMinSizeConstraintFromAllOfRef(Pageable pageable) { + return ResponseEntity.ok(Collections.emptyList()); + } + + @Override + public ResponseEntity> findPetsWithSizeConstraintFromAllOfRef(Pageable pageable) { + return ResponseEntity.ok(Collections.emptyList()); + } + // ── @PageableDefault ───────────────────────────────────────────────────── // @PageableDefault(page = 0, size = 25) @@ -106,6 +116,17 @@ public ResponseEntity> findPetsWithPageSizeDefaultsOnly(Pageable pagea return ResponseEntity.ok(Collections.emptyList()); } + // @PageableDefault(size = 7) + + @Override + public ResponseEntity> findPetsWithDefaultFromAllOfRef(Pageable pageable) { + if (pageable.getPageSize() != 7) { + throw new IllegalStateException( + "@PageableDefault size: expected 7, got " + pageable.getPageSize()); + } + return ResponseEntity.ok(Collections.emptyList()); + } + // ── @SortDefault ───────────────────────────────────────────────────────── // @SortDefault(sort = {"name"}, direction = DESC) diff --git a/samples/server/petstore/springboot-sort-validation/src/test/java/org/openapitools/api/PetApiValidationTest.java b/samples/server/petstore/springboot-sort-validation/src/test/java/org/openapitools/api/PetApiValidationTest.java index 7e3bceea5072..4cb3fda52b0d 100644 --- a/samples/server/petstore/springboot-sort-validation/src/test/java/org/openapitools/api/PetApiValidationTest.java +++ b/samples/server/petstore/springboot-sort-validation/src/test/java/org/openapitools/api/PetApiValidationTest.java @@ -178,4 +178,70 @@ void pageableDefaultAndSortDefaults_absentParamsResolveAllDefaults() throws Exce mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_ALL_DEFAULTS)) .andExpect(status().isOk()); } + + // ── @PageableDefault — size default from allOf $ref ─────────────────────── + // Endpoint: GET /pet/findWithDefaultFromAllOfRef @PageableDefault(size = 7) + // PetApiController asserts size == 7; returns 200 on success, throws on mismatch. + + @Test + void pageableDefault_absentSizeParamResolvesToSizeSevenDefault() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_DEFAULT_FROM_ALL_OF_REF)) + .andExpect(status().isOk()); + } + + // ── @ValidPageable — minSize constraint from allOf $ref ─────────────────── + // Endpoint: GET /pet/findWithMinSizeConstraintFromAllOfRef minSize = 5 + + @Test + void validPageable_sizeAboveMinimumReturns200() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "6")) + .andExpect(status().isOk()); + } + + @Test + void validPageable_sizeAtMinimumReturns200() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "5")) + .andExpect(status().isOk()); + } + + @Test + void validPageable_sizeBelowMinimumReturns400() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "4")) + .andExpect(status().isBadRequest()); + } + + @Test + void validPageable_unpagedPageableIsAllowedForMinConstraint() throws Exception { + // Unpaged Pageable (no params, no @PageableDefault) bypasses the validator per + // PageableConstraintValidator#isValid which returns true immediately for !isPaged(). + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_MIN_SIZE_CONSTRAINT_FROM_ALL_OF_REF)) + .andExpect(status().isOk()); + } + + // ── @ValidPageable — maxSize constraint from allOf $ref ─────────────────── + // Endpoint: GET /pet/findWithSizeConstraintFromAllOfRef maxSize = 75 + + @Test + void validPageable_sizeBelowMaximumReturns200_forSizeConstraintFromAllOfRef() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "50")) + .andExpect(status().isOk()); + } + + @Test + void validPageable_sizeAtMaximumReturns200_forSizeConstraintFromAllOfRef() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "75")) + .andExpect(status().isOk()); + } + + @Test + void validPageable_sizeExceedsMaximumReturns400_forSizeConstraintFromAllOfRef() throws Exception { + mockMvc.perform(get(PetApi.PATH_FIND_PETS_WITH_SIZE_CONSTRAINT_FROM_ALL_OF_REF) + .param("size", "76")) + .andExpect(status().isBadRequest()); + } } From cec4212c66a791c112df00e6f046273a02b7fb71 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 6 May 2026 12:42:46 +0200 Subject: [PATCH 06/21] move reusable code to shared ModelUtils.java --- .../languages/SpringPageableScanUtils.java | 84 +----------------- .../codegen/utils/ModelUtils.java | 88 +++++++++++++++++++ 2 files changed, 91 insertions(+), 81 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java index 74ae7ae65669..7e4c09009e1b 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java @@ -212,7 +212,7 @@ public static Map scanPageableDefaults( if (schema == null) { continue; } - Object defaultValue = resolveDefault(openAPI, schema); + Object defaultValue = ModelUtils.resolveDefault(openAPI, schema); if (defaultValue == null) { continue; } @@ -291,8 +291,8 @@ public static Map scanPageableConstraints( if (schema == null) { continue; } - BigDecimal maximum = resolveMaximum(openAPI, schema); - BigDecimal minimum = resolveMinimum(openAPI, schema); + BigDecimal maximum = ModelUtils.resolveMaximum(openAPI, schema); + BigDecimal minimum = ModelUtils.resolveMinimum(openAPI, schema); switch (param.getName()) { case "page": if (maximum != null) maxPage = maximum.intValue(); @@ -315,82 +315,4 @@ public static Map scanPageableConstraints( return result; } - // ------------------------------------------------------------------------- - // Private schema-resolution helpers - // ------------------------------------------------------------------------- - - /** - * Returns the effective {@code maximum} for the given schema, resolving through a top-level - * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). - * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive - * (smallest) value wins. - */ - private static BigDecimal resolveMaximum(OpenAPI openAPI, Schema schema) { - if (schema == null) return null; - if (schema.get$ref() != null) { - schema = ModelUtils.getReferencedSchema(openAPI, schema); - if (schema == null) return null; - } - BigDecimal result = schema.getMaximum(); - if (schema.getAllOf() != null) { - for (Schema allOfItem : schema.getAllOf()) { - Schema resolved = ModelUtils.getReferencedSchema(openAPI, allOfItem); - if (resolved != null && resolved.getMaximum() != null) { - if (result == null || resolved.getMaximum().compareTo(result) < 0) { - result = resolved.getMaximum(); - } - } - } - } - return result; - } - - /** - * Returns the effective {@code minimum} for the given schema, resolving through a top-level - * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). - * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive - * (largest) value wins. - */ - private static BigDecimal resolveMinimum(OpenAPI openAPI, Schema schema) { - if (schema == null) return null; - if (schema.get$ref() != null) { - schema = ModelUtils.getReferencedSchema(openAPI, schema); - if (schema == null) return null; - } - BigDecimal result = schema.getMinimum(); - if (schema.getAllOf() != null) { - for (Schema allOfItem : schema.getAllOf()) { - Schema resolved = ModelUtils.getReferencedSchema(openAPI, allOfItem); - if (resolved != null && resolved.getMinimum() != null) { - if (result == null || resolved.getMinimum().compareTo(result) > 0) { - result = resolved.getMinimum(); - } - } - } - } - return result; - } - - /** - * Returns the effective {@code default} for the given schema. Unlike constraints, the inline - * schema's default takes precedence (explicit per-endpoint override); falls back to the first - * non-null default found in {@code allOf} items. - */ - private static Object resolveDefault(OpenAPI openAPI, Schema schema) { - if (schema == null) return null; - if (schema.get$ref() != null) { - schema = ModelUtils.getReferencedSchema(openAPI, schema); - if (schema == null) return null; - } - if (schema.getDefault() != null) return schema.getDefault(); - if (schema.getAllOf() != null) { - for (Schema allOfItem : schema.getAllOf()) { - Schema resolved = ModelUtils.getReferencedSchema(openAPI, allOfItem); - if (resolved != null && resolved.getDefault() != null) { - return resolved.getDefault(); - } - } - } - return null; - } } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index 88346d9046f7..763769927c26 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -852,6 +852,94 @@ public static boolean isModelWithPropertiesOnly(Schema schema) { ); } + /** + * Returns the effective {@code maximum} for the given schema, resolving through a top-level + * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive + * (smallest) value wins. + * + * @param openAPI the OpenAPI document used to resolve {@code $ref}s + * @param schema the schema to inspect + * @return the effective maximum, or {@code null} if none is defined + */ + public static BigDecimal resolveMaximum(OpenAPI openAPI, Schema schema) { + if (schema == null) return null; + if (schema.get$ref() != null) { + schema = getReferencedSchema(openAPI, schema); + if (schema == null) return null; + } + BigDecimal result = schema.getMaximum(); + if (schema.getAllOf() != null) { + for (Schema allOfItem : schema.getAllOf()) { + Schema resolved = getReferencedSchema(openAPI, allOfItem); + if (resolved != null && resolved.getMaximum() != null) { + if (result == null || resolved.getMaximum().compareTo(result) < 0) { + result = resolved.getMaximum(); + } + } + } + } + return result; + } + + /** + * Returns the effective {@code minimum} for the given schema, resolving through a top-level + * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive + * (largest) value wins. + * + * @param openAPI the OpenAPI document used to resolve {@code $ref}s + * @param schema the schema to inspect + * @return the effective minimum, or {@code null} if none is defined + */ + public static BigDecimal resolveMinimum(OpenAPI openAPI, Schema schema) { + if (schema == null) return null; + if (schema.get$ref() != null) { + schema = getReferencedSchema(openAPI, schema); + if (schema == null) return null; + } + BigDecimal result = schema.getMinimum(); + if (schema.getAllOf() != null) { + for (Schema allOfItem : schema.getAllOf()) { + Schema resolved = getReferencedSchema(openAPI, allOfItem); + if (resolved != null && resolved.getMinimum() != null) { + if (result == null || resolved.getMinimum().compareTo(result) > 0) { + result = resolved.getMinimum(); + } + } + } + } + return result; + } + + /** + * Returns the effective {@code default} for the given schema, resolving through a top-level + * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * Unlike constraints, the inline schema's default takes precedence (explicit per-endpoint + * override); falls back to the first non-null default found in {@code allOf} items. + * + * @param openAPI the OpenAPI document used to resolve {@code $ref}s + * @param schema the schema to inspect + * @return the effective default value, or {@code null} if none is defined + */ + public static Object resolveDefault(OpenAPI openAPI, Schema schema) { + if (schema == null) return null; + if (schema.get$ref() != null) { + schema = getReferencedSchema(openAPI, schema); + if (schema == null) return null; + } + if (schema.getDefault() != null) return schema.getDefault(); + if (schema.getAllOf() != null) { + for (Schema allOfItem : schema.getAllOf()) { + Schema resolved = getReferencedSchema(openAPI, allOfItem); + if (resolved != null && resolved.getDefault() != null) { + return resolved.getDefault(); + } + } + } + return null; + } + public static boolean hasValidation(Schema sc) { return ( sc.getMaxItems() != null || From 7151bb390e6defdcc4680c9b33e9121bec385f7f Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 6 May 2026 12:46:10 +0200 Subject: [PATCH 07/21] add tests --- .../codegen/utils/ModelUtilsTest.java | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java index 906ace829bcb..a445a02391b8 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java @@ -757,4 +757,234 @@ public void getParentNameMultipleInterfacesTest() { Schema composedSchema = allSchemas.get("RandomAnimalsResponse_animals_inner"); assertNull(ModelUtils.getParentName(composedSchema, allSchemas)); } + + // ------------------------------------------------------------------------- + // resolveMaximum + // ------------------------------------------------------------------------- + + @Test + public void resolveMaximum_nullSchema_returnsNull() { + assertNull(ModelUtils.resolveMaximum(new OpenAPI(), null)); + } + + @Test + public void resolveMaximum_noMaximumDefined_returnsNull() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + assertNull(ModelUtils.resolveMaximum(openAPI, schema)); + } + + @Test + public void resolveMaximum_inlineMaximum_returnsIt() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMaximum(BigDecimal.valueOf(100)); + assertEquals(ModelUtils.resolveMaximum(openAPI, schema), BigDecimal.valueOf(100)); + } + + @Test + public void resolveMaximum_refToSchemaWithMaximum_resolvesRef() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema refTarget = new IntegerSchema(); + refTarget.setMaximum(BigDecimal.valueOf(50)); + openAPI.getComponents().addSchemas("MyInt", refTarget); + + Schema ref = new Schema<>().$ref("#/components/schemas/MyInt"); + assertEquals(ModelUtils.resolveMaximum(openAPI, ref), BigDecimal.valueOf(50)); + } + + @Test + public void resolveMaximum_allOf_returnsMostRestrictive() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // allOf item with max=200 and item with max=50 — 50 should win + Schema loose = new IntegerSchema(); + loose.setMaximum(BigDecimal.valueOf(200)); + openAPI.getComponents().addSchemas("Loose", loose); + + Schema strict = new IntegerSchema(); + strict.setMaximum(BigDecimal.valueOf(50)); + openAPI.getComponents().addSchemas("Strict", strict); + + Schema schema = new Schema<>().allOf(Arrays.asList( + new Schema<>().$ref("#/components/schemas/Loose"), + new Schema<>().$ref("#/components/schemas/Strict") + )); + assertEquals(ModelUtils.resolveMaximum(openAPI, schema), BigDecimal.valueOf(50)); + } + + @Test + public void resolveMaximum_inlineAndAllOf_mostRestrictiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // allOf item has maximum=30, which is more restrictive than inline maximum=100 + Schema allOfItem = new IntegerSchema(); + allOfItem.setMaximum(BigDecimal.valueOf(30)); + openAPI.getComponents().addSchemas("Base", allOfItem); + + Schema schema = new IntegerSchema(); + schema.setMaximum(BigDecimal.valueOf(100)); + schema.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + assertEquals(ModelUtils.resolveMaximum(openAPI, schema), BigDecimal.valueOf(30)); + } + + @Test + public void resolveMaximum_allOfItemWithoutMaximum_ignored() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + openAPI.getComponents().addSchemas("NoMax", new IntegerSchema()); // no maximum + + Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/NoMax"))); + assertNull(ModelUtils.resolveMaximum(openAPI, schema)); + } + + // ------------------------------------------------------------------------- + // resolveMinimum + // ------------------------------------------------------------------------- + + @Test + public void resolveMinimum_nullSchema_returnsNull() { + assertNull(ModelUtils.resolveMinimum(new OpenAPI(), null)); + } + + @Test + public void resolveMinimum_noMinimumDefined_returnsNull() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + assertNull(ModelUtils.resolveMinimum(openAPI, new IntegerSchema())); + } + + @Test + public void resolveMinimum_inlineMinimum_returnsIt() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMinimum(BigDecimal.valueOf(1)); + assertEquals(ModelUtils.resolveMinimum(openAPI, schema), BigDecimal.valueOf(1)); + } + + @Test + public void resolveMinimum_refToSchemaWithMinimum_resolvesRef() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema refTarget = new IntegerSchema(); + refTarget.setMinimum(BigDecimal.valueOf(5)); + openAPI.getComponents().addSchemas("MyInt", refTarget); + + Schema ref = new Schema<>().$ref("#/components/schemas/MyInt"); + assertEquals(ModelUtils.resolveMinimum(openAPI, ref), BigDecimal.valueOf(5)); + } + + @Test + public void resolveMinimum_allOf_returnsMostRestrictive() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // allOf item with min=1 and item with min=10 — 10 should win (larger = more restrictive lower bound) + Schema permissive = new IntegerSchema(); + permissive.setMinimum(BigDecimal.valueOf(1)); + openAPI.getComponents().addSchemas("Permissive", permissive); + + Schema strict = new IntegerSchema(); + strict.setMinimum(BigDecimal.valueOf(10)); + openAPI.getComponents().addSchemas("Strict", strict); + + Schema schema = new Schema<>().allOf(Arrays.asList( + new Schema<>().$ref("#/components/schemas/Permissive"), + new Schema<>().$ref("#/components/schemas/Strict") + )); + assertEquals(ModelUtils.resolveMinimum(openAPI, schema), BigDecimal.valueOf(10)); + } + + @Test + public void resolveMinimum_inlineAndAllOf_mostRestrictiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // allOf item has minimum=20, which is more restrictive than inline minimum=0 + Schema allOfItem = new IntegerSchema(); + allOfItem.setMinimum(BigDecimal.valueOf(20)); + openAPI.getComponents().addSchemas("Base", allOfItem); + + Schema schema = new IntegerSchema(); + schema.setMinimum(BigDecimal.valueOf(0)); + schema.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + assertEquals(ModelUtils.resolveMinimum(openAPI, schema), BigDecimal.valueOf(20)); + } + + @Test + public void resolveMinimum_allOfItemWithoutMinimum_ignored() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + openAPI.getComponents().addSchemas("NoMin", new IntegerSchema()); + + Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/NoMin"))); + assertNull(ModelUtils.resolveMinimum(openAPI, schema)); + } + + // ------------------------------------------------------------------------- + // resolveDefault + // ------------------------------------------------------------------------- + + @Test + public void resolveDefault_nullSchema_returnsNull() { + assertNull(ModelUtils.resolveDefault(new OpenAPI(), null)); + } + + @Test + public void resolveDefault_noDefaultDefined_returnsNull() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + assertNull(ModelUtils.resolveDefault(openAPI, new IntegerSchema())); + } + + @Test + public void resolveDefault_inlineDefault_returnsIt() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setDefault(10); + assertEquals(ModelUtils.resolveDefault(openAPI, schema), 10); + } + + @Test + public void resolveDefault_refToSchemaWithDefault_resolvesRef() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema refTarget = new IntegerSchema(); + refTarget.setDefault(0); + openAPI.getComponents().addSchemas("MyInt", refTarget); + + Schema ref = new Schema<>().$ref("#/components/schemas/MyInt"); + assertEquals(ModelUtils.resolveDefault(openAPI, ref), 0); + } + + @Test + public void resolveDefault_allOfItemHasDefault_returnsIt() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema allOfItem = new IntegerSchema(); + allOfItem.setDefault(20); + openAPI.getComponents().addSchemas("Base", allOfItem); + + // Inline schema has no default; allOf item has default=20 + Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + assertEquals(ModelUtils.resolveDefault(openAPI, schema), 20); + } + + @Test + public void resolveDefault_inlineDefaultTakesPrecedenceOverAllOf() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema allOfItem = new IntegerSchema(); + allOfItem.setDefault(99); + openAPI.getComponents().addSchemas("Base", allOfItem); + + // Inline schema default=5 should win over allOf item default=99 + Schema schema = new IntegerSchema(); + schema.setDefault(5); + schema.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + assertEquals(ModelUtils.resolveDefault(openAPI, schema), 5); + } + + @Test + public void resolveDefault_allOfItemsNoDefault_returnsNull() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + openAPI.getComponents().addSchemas("Base", new IntegerSchema()); // no default + + Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + assertNull(ModelUtils.resolveDefault(openAPI, schema)); + } + + @Test + public void resolveDefault_stringDefault_returnsIt() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new StringSchema(); + schema.setDefault("hello"); + assertEquals(ModelUtils.resolveDefault(openAPI, schema), "hello"); + } } From daa33e017de3ee22ba1b31d2fddf10b7017f31bb Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 6 May 2026 15:01:38 +0200 Subject: [PATCH 08/21] fix CR suggestion and add tests --- .../languages/SpringPageableScanUtils.java | 2 +- .../SpringPageableScanUtilsTest.java | 154 ++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java index 7e4c09009e1b..88311a2b2ddd 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java @@ -164,7 +164,7 @@ public static Map> scanSortValidationEnums( Schema enumSchema = schema; if (ModelUtils.isArraySchema(schema)) { enumSchema = schema.getItems(); - if (enumSchema.get$ref() != null) { + if (enumSchema != null && enumSchema.get$ref() != null) { enumSchema = ModelUtils.getReferencedSchema(openAPI, enumSchema); } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java new file mode 100644 index 000000000000..6a27ccb216a7 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java @@ -0,0 +1,154 @@ +package org.openapitools.codegen.languages; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.Parameter; +import org.testng.annotations.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Unit tests for {@link SpringPageableScanUtils}. + */ +public class SpringPageableScanUtilsTest { + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Builds an OpenAPI doc with a single GET /items operation marked x-spring-paginated. + */ + private static OpenAPI buildPageableOperation(Parameter sortParam) { + Operation op = new Operation(); + op.setOperationId("listItems"); + op.addExtension("x-spring-paginated", true); + op.addParametersItem(sortParam); + + PathItem pathItem = new PathItem(); + pathItem.setGet(op); + + Paths paths = new Paths(); + paths.addPathItem("/items", pathItem); + + OpenAPI openAPI = new OpenAPI(); + openAPI.setPaths(paths); + return openAPI; + } + + // ------------------------------------------------------------------------- + // scanSortValidationEnums — NPE regression for array schema without items + // ------------------------------------------------------------------------- + + /** + * Regression: array sort parameter with no {@code items} must not throw NPE. + * {@code isArraySchema()} returns {@code true} but {@code schema.getItems()} returns + * {@code null}, which would NPE on the subsequent {@code enumSchema.get$ref()} call + * before the fix. + * + *

+     * parameters:
+     *   - name: sort
+     *     in: query
+     *     schema:
+     *       type: array
+     *       # items: intentionally absent
+     * 
+ */ + @Test + public void scanSortValidationEnums_arraySchemaWithNoItems_doesNotThrow_and_returnsEmptyMap() { + // sort param: type=array but items intentionally absent + Schema sortSchema = new ArraySchema(); + // getItems() == null + assertThat(sortSchema.getItems()).isNull(); + Parameter sortParam = new Parameter().name("sort").schema(sortSchema); + OpenAPI openAPI = buildPageableOperation(sortParam); + + // does not throw NPE + assertThatCode(() -> SpringPageableScanUtils.scanSortValidationEnums(openAPI, false)) + .doesNotThrowAnyException(); + + // and returns empty map + Map> result = SpringPageableScanUtils.scanSortValidationEnums(openAPI, false); + assertThat(result).isEmpty(); + } + + // ------------------------------------------------------------------------- + // scanSortValidationEnums — happy path + // ------------------------------------------------------------------------- + + /** + *
+     * parameters:
+     *   - name: sort
+     *     in: query
+     *     schema:
+     *       type: array # sort as multi-column
+     *       items:
+     *         type: string
+     *         enum: ["name,asc", "name,desc", "id,asc"]
+     * 
+ */ + @Test + public void scanSortValidationEnums_arraySchemaWithEnumItems_returnsMappedEnums() { + Schema items = new StringSchema()._enum(List.of("name,asc", "name,desc", "id,asc")); + Schema sortSchema = new ArraySchema().items(items); + Parameter sortParam = new Parameter().name("sort").schema(sortSchema); + OpenAPI openAPI = buildPageableOperation(sortParam); + + Map> result = SpringPageableScanUtils.scanSortValidationEnums(openAPI, false); + assertThat(result) + .containsKey("listItems") + .satisfies(m -> assertThat(m.get("listItems")) + .containsExactly("name,asc", "name,desc", "id,asc")); + } + + /** + *
+     * parameters:
+     *   - name: sort
+     *     in: query
+     *     schema:
+     *       type: string # sort as single-column
+     *       enum: ["id,asc", "id,desc"]
+     * 
+ */ + @Test + public void scanSortValidationEnums_nonArraySortSchemaWithEnum_returnsIt() { + Schema sortSchema = new StringSchema()._enum(List.of("id,asc", "id,desc")); + Parameter sortParam = new Parameter().name("sort").schema(sortSchema); + OpenAPI openAPI = buildPageableOperation(sortParam); + + Map> result = SpringPageableScanUtils.scanSortValidationEnums(openAPI, false); + assertThat(result) + .containsKey("listItems") + .satisfies(m -> assertThat(m.get("listItems")).containsExactly("id,asc", "id,desc")); + } + + /** + *
+     * parameters:
+     *   - name: sort
+     *     in: query
+     *     schema:
+     *       type: string # sort as single-column
+     *       # enum: absent — no validation constraint
+     * 
+ */ + @Test + public void scanSortValidationEnums_sortSchemaWithNoEnum_returnsEmptyMap() { + Parameter sortParam = new Parameter().name("sort").schema(new StringSchema()); + OpenAPI openAPI = buildPageableOperation(sortParam); + + assertThat(SpringPageableScanUtils.scanSortValidationEnums(openAPI, false)).isEmpty(); + } +} From 609fc8757033a63a1bba111c18932a28480ee291 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 6 May 2026 16:42:18 +0200 Subject: [PATCH 09/21] suppress paged schemas correctly when modelNameSuffix/Prefix is set --- .../languages/KotlinSpringServerCodegen.java | 19 ++-- .../languages/PagedModelScanUtils.java | 86 +++++++++++++++++-- .../codegen/languages/SpringCodegen.java | 19 ++-- .../java/spring/SpringCodegenTest.java | 53 ++++++++++++ .../spring/KotlinSpringServerCodegenTest.java | 56 +++++++++++- .../languages/PagedModelScanUtilsTest.java | 53 ++++++++++++ 6 files changed, 262 insertions(+), 24 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index 04bbdabff195..9f35a5d8012c 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java @@ -1214,7 +1214,7 @@ public void preprocessOpenAPI(OpenAPI openAPI) { } if (substituteGenericPagedModel) { - pagedModelRegistry = PagedModelScanUtils.scanPagedModels(openAPI); + pagedModelRegistry = PagedModelScanUtils.scanPagedModels(openAPI, this::toModelName); if (!pagedModelRegistry.isEmpty()) { boolean customMapping = importMapping.containsKey("PagedModel"); importMapping.putIfAbsent("PagedModel", configPackage + ".PagedModel"); @@ -1385,24 +1385,27 @@ public Map postProcessAllModels(Map objs) if (getAnnotationLibrary() == AnnotationLibrary.NONE) { // No @ApiResponse annotations are generated when annotationLibrary=none, // so paged schemas are not referenced anywhere → safe to suppress. - Set metaSchemasToCheck = new HashSet<>(); + // metaSchemasToCheck maps transformed name (for imports check) → raw name (for objs.remove) + Map metaSchemasToCheck = new LinkedHashMap<>(); for (PagedModelScanUtils.DetectedPagedModel detected : pagedModelRegistry.values()) { if (detected.metaSchemaName != null) { - metaSchemasToCheck.add(detected.metaSchemaName); + metaSchemasToCheck.put(detected.metaSchemaName, detected.rawMetaSchemaName); } } // Remove paged schemas first so reference checks below reflect the post-suppression state. for (Map.Entry entry : pagedModelRegistry.entrySet()) { - String schemaName = entry.getKey(); PagedModelScanUtils.DetectedPagedModel detected = entry.getValue(); - if (objs.remove(schemaName) != null) { + // objs is keyed by raw schema name (DefaultGenerator uses the raw OpenAPI name as key) + if (objs.remove(detected.rawSchemaName) != null) { LOGGER.info("substituteGenericPagedModel: suppressing model '{}' — replaced by PagedModel<{}>", - schemaName, detected.itemSchemaName); + detected.rawSchemaName, detected.itemSchemaName); } } // Suppress meta schemas only when no remaining (non-suppressed) schema references them. // Example: if SearchResult has a 'page: PageMeta' property, PageMeta must be kept. - for (String metaName : metaSchemasToCheck) { + for (Map.Entry metaEntry : metaSchemasToCheck.entrySet()) { + String metaName = metaEntry.getKey(); // transformed — matches cm.imports values + String rawMetaName = metaEntry.getValue(); // raw — matches objs key boolean referencedElsewhere = objs.values().stream() .flatMap(mm -> mm.getModels().stream()) .map(ModelMap::getModel) @@ -1410,7 +1413,7 @@ public Map postProcessAllModels(Map objs) if (referencedElsewhere) { LOGGER.info("substituteGenericPagedModel: keeping pagination metadata model '{}'" + " — referenced by a non-paged schema", metaName); - } else if (objs.remove(metaName) != null) { + } else if (objs.remove(rawMetaName) != null) { LOGGER.info("substituteGenericPagedModel: suppressing pagination metadata model '{}'" + " — replaced by PagedModel.PageMetadata", metaName); } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java index 67279c10d818..f948e4da1d3e 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PagedModelScanUtils.java @@ -21,6 +21,7 @@ import org.openapitools.codegen.utils.ModelUtils; import java.util.*; +import java.util.function.UnaryOperator; /** * Language-agnostic utility for detecting OpenAPI schemas that represent paginated responses @@ -61,21 +62,56 @@ private PagedModelScanUtils() {} /** * Carries the result of a single detected paged-model schema. * - * @param schemaName Name of the detected schema to suppress (e.g. {@code UserPage}). - * @param itemSchemaName Simple name of the array item type (e.g. {@code User}). - * @param metaSchemaName Name of the pagination-metadata schema to suppress - * (e.g. {@code PageMetadata}), or {@code null} if it could not - * be resolved to a named component. + *

Two name variants are stored for each schema:

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

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

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

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

+ * + *

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

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

The heuristic checks that at least {@value #PAGINATION_FIELD_THRESHOLD} of the * well-known field names ({@code size}, {@code number}, {@code page}, diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java index 8870a8ef9359..bfb320edfd47 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java @@ -872,7 +872,7 @@ public void preprocessOpenAPI(OpenAPI openAPI) { } if (substituteGenericPagedModel) { - pagedModelRegistry = PagedModelScanUtils.scanPagedModels(openAPI); + pagedModelRegistry = PagedModelScanUtils.scanPagedModels(openAPI, this::toModelName); if (!pagedModelRegistry.isEmpty()) { boolean customMapping = importMapping.containsKey("PagedModel"); importMapping.putIfAbsent("PagedModel", configPackage + ".PagedModel"); @@ -1452,24 +1452,27 @@ public Map postProcessAllModels(Map objs) if (getAnnotationLibrary() == AnnotationLibrary.NONE) { // No @ApiResponse annotations are generated when annotationLibrary=none, // so paged schemas are not referenced anywhere → safe to suppress. - Set metaSchemasToCheck = new HashSet<>(); + // metaSchemasToCheck maps transformed name (for imports check) → raw name (for objs.remove) + Map metaSchemasToCheck = new LinkedHashMap<>(); for (PagedModelScanUtils.DetectedPagedModel detected : pagedModelRegistry.values()) { if (detected.metaSchemaName != null) { - metaSchemasToCheck.add(detected.metaSchemaName); + metaSchemasToCheck.put(detected.metaSchemaName, detected.rawMetaSchemaName); } } // Remove paged schemas first so reference checks below reflect the post-suppression state. for (Map.Entry entry : pagedModelRegistry.entrySet()) { - String schemaName = entry.getKey(); PagedModelScanUtils.DetectedPagedModel detected = entry.getValue(); - if (objs.remove(schemaName) != null) { + // objs is keyed by raw schema name (DefaultGenerator uses the raw OpenAPI name as key) + if (objs.remove(detected.rawSchemaName) != null) { LOGGER.info("substituteGenericPagedModel: suppressing model '{}' — replaced by PagedModel<{}>", - schemaName, detected.itemSchemaName); + detected.rawSchemaName, detected.itemSchemaName); } } // Suppress meta schemas only when no remaining (non-suppressed) schema references them. // Example: if SearchResult has a 'page: PageMeta' property, PageMeta must be kept. - for (String metaName : metaSchemasToCheck) { + for (Map.Entry metaEntry : metaSchemasToCheck.entrySet()) { + String metaName = metaEntry.getKey(); // transformed — matches cm.imports values + String rawMetaName = metaEntry.getValue(); // raw — matches objs key boolean referencedElsewhere = objs.values().stream() .flatMap(mm -> mm.getModels().stream()) .map(ModelMap::getModel) @@ -1477,7 +1480,7 @@ public Map postProcessAllModels(Map objs) if (referencedElsewhere) { LOGGER.info("substituteGenericPagedModel: keeping pagination metadata model '{}'" + " — referenced by a non-paged schema", metaName); - } else if (objs.remove(metaName) != null) { + } else if (objs.remove(rawMetaName) != null) { LOGGER.info("substituteGenericPagedModel: suppressing pagination metadata model '{}'" + " — replaced by PagedModel.PageMetadata", metaName); } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index ae54a9ca85a2..f9d37a199c66 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java @@ -7642,6 +7642,59 @@ private Map springCloudPagedModelProps() { } + // ------------------------------------------------------------------------- + // substituteGenericPagedModel — modelNameSuffix / modelNamePrefix + // ------------------------------------------------------------------------- + + @Test + public void substituteGenericPagedModel_withModelNameSuffix_replacesReturnType() throws IOException { + // When modelNameSuffix is set the returnBaseType includes the suffix, + // so the registry lookup must also use the suffix-applied key. + Map props = commonPagedModelProps(); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", SPRING_BOOT, props, + configurator -> configurator.addAdditionalProperty("modelNameSuffix", "Dto")); + + // listUsers returns UserPage → suffix applied → UserPageDto → replaced with PagedModel + JavaFileAssert.assertThat(files.get("UserApi.java")) + .assertMethod("listUsers") + .hasReturnType("ResponseEntity>"); + } + + @Test + public void substituteGenericPagedModel_withModelNamePrefix_replacesReturnType() throws IOException { + // When modelNamePrefix is set the returnBaseType includes the prefix, + // so the registry lookup must also use the prefix-applied key. + Map props = commonPagedModelProps(); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", SPRING_BOOT, props, + configurator -> configurator.addAdditionalProperty("modelNamePrefix", "My")); + + // listUsers returns UserPage → prefix applied → MyUserPage → replaced with PagedModel + JavaFileAssert.assertThat(files.get("UserApi.java")) + .assertMethod("listUsers") + .hasReturnType("ResponseEntity>"); + } + + @Test + public void substituteGenericPagedModel_withModelNameSuffix_suppressesPagedSchemasWhenNoAnnotations() + throws IOException { + // Verify schema suppression also works correctly under modelNameSuffix + // (objs keys are suffix-applied, registry keys must match them). + Map props = noAnnotationPagedModelProps(); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", SPRING_BOOT, props, + configurator -> configurator.addAdditionalProperty("modelNameSuffix", "Dto")); + + assertThat(files).doesNotContainKey("UserPageDto.java"); + assertThat(files).doesNotContainKey("OrderPageDto.java"); + assertThat(files).doesNotContainKey("PetPageAllOfDto.java"); + } + + @DataProvider(name = "replaceOneOf") public Object[][] replaceOneOf() { return new Object[][]{ diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index 007a4aeb2634..0e3f0c4c5b1b 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -5958,7 +5958,61 @@ private Map springCloudKotlinPagedModelProps() { return props; } - @Test(description = "oneOf with discriminator generates thin sealed interface with Jackson annotations") + // ------------------------------------------------------------------------- + // substituteGenericPagedModel — modelNameSuffix / modelNamePrefix + // ------------------------------------------------------------------------- + + @Test + public void substituteGenericPagedModel_withModelNameSuffix_replacesReturnType() throws IOException { + // When modelNameSuffix is set the returnBaseType includes the suffix, + // so the registry lookup must also use the suffix-applied key. + Map props = commonKotlinPagedModelProps(); + props.put("modelNameSuffix", "Dto"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", props); + + // listUsers returns UserPage → suffix applied → UserPageDto → replaced with PagedModel + File userApi = files.get("UserApi.kt"); + assertThat(userApi).isNotNull(); + String content = Files.readString(userApi.toPath()); + assertThat(content).contains("PagedModel"); + } + + @Test + public void substituteGenericPagedModel_withModelNamePrefix_replacesReturnType() throws IOException { + // When modelNamePrefix is set the returnBaseType includes the prefix, + // so the registry lookup must also use the prefix-applied key. + Map props = commonKotlinPagedModelProps(); + props.put("modelNamePrefix", "My"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", props); + + // listUsers returns UserPage → prefix applied → MyUserPage → replaced with PagedModel + File userApi = files.get("UserApi.kt"); + assertThat(userApi).isNotNull(); + String content = Files.readString(userApi.toPath()); + assertThat(content).contains("PagedModel"); + } + + @Test + public void substituteGenericPagedModel_withModelNameSuffix_suppressesPagedSchemasWhenNoAnnotations() + throws IOException { + // Verify schema suppression also works correctly under modelNameSuffix + // (objs keys are suffix-applied, registry keys must match them). + Map props = noAnnotationKotlinPagedModelProps(); + props.put("modelNameSuffix", "Dto"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-paged-model.yaml", props); + + assertThat(files).doesNotContainKey("UserPageDto.kt"); + assertThat(files).doesNotContainKey("OrderPageDto.kt"); + assertThat(files).doesNotContainKey("PetPageAllOfDto.kt"); + } + + public void testOneOfWithDiscriminatorGeneratesThinInterface() throws IOException { File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); output.deleteOnExit(); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/PagedModelScanUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/PagedModelScanUtilsTest.java index ef39ad954208..3aedae62e724 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/PagedModelScanUtilsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/PagedModelScanUtilsTest.java @@ -353,4 +353,57 @@ public void extractSchemaNameFromRef_returnsRefAsIsWhenNoSlash() { public void extractSchemaNameFromRef_returnsNullForNull() { assertThat(PagedModelScanUtils.extractSchemaNameFromRef(null)).isNull(); } + + // ------------------------------------------------------------------------- + // scanPagedModels(OpenAPI, UnaryOperator) — transform overload + // ------------------------------------------------------------------------- + + @Test + public void scanPagedModels_withTransform_appliesTransformToKeySchemaNameAndMetaSchemaName() { + // Build a minimal paged schema so the scan detects one entry. + ArraySchema contentSchema = new ArraySchema(); + contentSchema.setItems(new Schema<>().$ref(ref("User"))); + + ObjectSchema userPageSchema = new ObjectSchema(); + Map props = new LinkedHashMap<>(); + props.put("content", contentSchema); + props.put("page", new Schema<>().$ref(ref("PageMetadata"))); + userPageSchema.setProperties(props); + + Map schemas = new LinkedHashMap<>(); + schemas.put("PageMetadata", pageMetadataSchema()); + schemas.put("User", new ObjectSchema()); + schemas.put("UserPage", userPageSchema); + + OpenAPI openAPI = buildOpenAPI(schemas); + + // Simulate a generator that appends "Dto" to every model name. + Map result = + PagedModelScanUtils.scanPagedModels(openAPI, name -> name + "Dto"); + + // Key, schemaName, and metaSchemaName must all have the suffix applied. + assertThat(result).containsKey("UserPageDto"); + assertThat(result).doesNotContainKey("UserPage"); + + PagedModelScanUtils.DetectedPagedModel detected = result.get("UserPageDto"); + assertThat(detected.schemaName).isEqualTo("UserPageDto"); + assertThat(detected.metaSchemaName).isEqualTo("PageMetadataDto"); + // itemSchemaName is intentionally left raw (transform is applied at call site). + assertThat(detected.itemSchemaName).isEqualTo("User"); + // Raw names must be preserved for objs.remove() in postProcessAllModels. + assertThat(detected.rawSchemaName).isEqualTo("UserPage"); + assertThat(detected.rawMetaSchemaName).isEqualTo("PageMetadata"); + } + + @Test + public void scanPagedModels_withTransform_returnsEmptyWhenNoSchemasDetected() { + OpenAPI openAPI = new OpenAPI(); + openAPI.setComponents(new Components()); + + Map result = + PagedModelScanUtils.scanPagedModels(openAPI, name -> name + "Dto"); + + assertThat(result).isEmpty(); + } } + From ffdb9e32f75d19a8e28fbbd9c6996779ca8763a3 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 7 May 2026 12:56:55 +0200 Subject: [PATCH 10/21] refactor: enhance pageable constraints handling with exclusive bounds support --- .../languages/SpringPageableScanUtils.java | 23 +- .../codegen/utils/ModelUtils.java | 242 +++++++++++++---- .../SpringPageableScanUtilsTest.java | 124 +++++++++ .../codegen/utils/ModelUtilsTest.java | 254 +++++++++++++++--- 4 files changed, 544 insertions(+), 99 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java index 88311a2b2ddd..a8a8173e47c3 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java @@ -24,7 +24,6 @@ import io.swagger.v3.oas.models.parameters.Parameter; import org.openapitools.codegen.utils.ModelUtils; -import java.math.BigDecimal; import java.util.*; import java.util.stream.Collectors; @@ -186,7 +185,7 @@ public static Map> scanSortValidationEnums( * and {@code sort} parameters. * * @return map from operationId to {@link PageableDefaultsData} (only operations with at - * least one default are included) + * least one default are included) */ public static Map scanPageableDefaults( OpenAPI openAPI, boolean autoXSpringPaginated) { @@ -265,7 +264,7 @@ public static Map scanPageableDefaults( * {@code $ref} schemas so that constraints defined on shared component schemas are honoured. * * @return map from operationId to {@link PageableConstraintsData} (only operations with - * at least one constraint are included) + * at least one constraint are included) */ public static Map scanPageableConstraints( OpenAPI openAPI, boolean autoXSpringPaginated) { @@ -291,16 +290,22 @@ public static Map scanPageableConstraints( if (schema == null) { continue; } - BigDecimal maximum = ModelUtils.resolveMaximum(openAPI, schema); - BigDecimal minimum = ModelUtils.resolveMinimum(openAPI, schema); + ModelUtils.ResolvedMaxBound maxBound = ModelUtils.resolveMaximumBound(openAPI, schema); + ModelUtils.ResolvedMinBound minBound = ModelUtils.resolveMinimumBound(openAPI, schema); + Integer adjustedMaxBound = maxBound == null + ? null + : (maxBound.exclusive ? maxBound.maxBound.intValue() - 1 : maxBound.maxBound.intValue()); + Integer adjustedMinBound = minBound == null + ? null + : (minBound.exclusive ? minBound.minBound.intValue() + 1 : minBound.minBound.intValue()); switch (param.getName()) { case "page": - if (maximum != null) maxPage = maximum.intValue(); - if (minimum != null) minPage = minimum.intValue(); + if (adjustedMaxBound != null) maxPage = adjustedMaxBound; + if (adjustedMinBound != null) minPage = adjustedMinBound; break; case "size": - if (maximum != null) maxSize = maximum.intValue(); - if (minimum != null) minSize = minimum.intValue(); + if (adjustedMaxBound != null) maxSize = adjustedMaxBound; + if (adjustedMinBound != null) minSize = adjustedMinBound; break; default: break; diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index 763769927c26..89fc6e43ebf6 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -36,6 +36,8 @@ import io.swagger.v3.parser.util.SchemaTypeUtil; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.openapitools.codegen.CodegenConfig; import org.openapitools.codegen.CodegenModel; import org.openapitools.codegen.IJsonSchemaValidationProperties; @@ -852,64 +854,182 @@ public static boolean isModelWithPropertiesOnly(Schema schema) { ); } + public static final class ResolvedMaxBound implements Comparable { + + public final BigDecimal maxBound; + public final boolean exclusive; + + private ResolvedMaxBound(BigDecimal maxBound, boolean exclusive) { + this.maxBound = maxBound; + this.exclusive = exclusive; + } + + @Nullable + public static ResolvedMaxBound getSmallerMaxBound(@Nullable ResolvedMaxBound first, @Nullable ResolvedMaxBound second) { + if (first == null && second == null) { + return null; + } + if (first != null && second != null) { + boolean firstIsSmallerOrSame = first.compareTo(second) <= 0; + return firstIsSmallerOrSame ? first : second; + } + if (second == null) { + return first; + } + return second; + } + + @Nullable + public static ResolvedMaxBound createResolvedMaxBound(@Nullable BigDecimal maxBound, boolean exclusive) { + return maxBound == null ? null : new ResolvedMaxBound(maxBound, exclusive); + } + + @Override + public int compareTo(@NonNull ResolvedMaxBound o) { + // lower maximum is lower + int comparison = this.maxBound.compareTo(o.maxBound); + if (comparison == 0) { + // if they are identical, then the one with exclusive is lower maximum + return Boolean.compare(o.exclusive, this.exclusive); + } + return comparison; + } + } + + public static final class ResolvedMinBound implements Comparable { + + public final BigDecimal minBound; + public final boolean exclusive; + + private ResolvedMinBound(BigDecimal minBound, boolean exclusive) { + this.minBound = minBound; + this.exclusive = exclusive; + } + + @Nullable + public static ResolvedMinBound getLargerMinBound(@Nullable ResolvedMinBound first, @Nullable ResolvedMinBound second) { + if (first == null && second == null) { + return null; + } + if (first != null && second != null) { + boolean firstIsLargerOrSame = first.compareTo(second) >= 0; + return firstIsLargerOrSame ? first : second; + } + if (second == null) { + return first; + } + return second; + } + + @Nullable + public static ResolvedMinBound createResolvedMinBound(@Nullable BigDecimal minBound, boolean exclusive) { + return minBound == null ? null : new ResolvedMinBound(minBound, exclusive); + } + + @Override + public int compareTo(@NonNull ResolvedMinBound o) { + //lower minimum is lower + int comparison = this.minBound.compareTo(o.minBound); + // if they are identical, then the one without exclusive is lower minimum + if (comparison == 0) { + return Boolean.compare(this.exclusive, o.exclusive); + } + return comparison; + } + } + /** - * Returns the effective {@code maximum} for the given schema, resolving through a top-level - * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * Extracts the effective maximum bound from a single (non-allOf, already-dereferenced) schema, + * taking both OAS 3.0 boolean {@code exclusiveMaximum} and OAS 3.1 numeric + * {@code exclusiveMaximum} into account. + */ + @Nullable + private static ResolvedMaxBound extractMaxBound(Schema schema) { + return ResolvedMaxBound.getSmallerMaxBound( + // 3.0 - 3.1 maximum (with 3.0 possible exclusive) + ResolvedMaxBound.createResolvedMaxBound(schema.getMaximum(), Boolean.TRUE.equals(schema.getExclusiveMaximum())), + // 3.1 exclusive maximum + ResolvedMaxBound.createResolvedMaxBound(schema.getExclusiveMaximumValue(), true) + ); + } + + /** + * Extracts the effective minimum bound from a single (non-allOf, already-dereferenced) schema, + * taking both OAS 3.0 boolean {@code exclusiveMinimum} and OAS 3.1 numeric + * {@code exclusiveMinimum} into account. + */ + @Nullable + private static ResolvedMinBound extractMinBound(Schema schema) { + return ResolvedMinBound.getLargerMinBound( + // 3.0 - 3.1 minimum (with 3.0 possible exclusive) + ResolvedMinBound.createResolvedMinBound(schema.getMinimum(), Boolean.TRUE.equals(schema.getExclusiveMinimum())), + // 3.1 exclusive minimum + ResolvedMinBound.createResolvedMinBound(schema.getExclusiveMinimumValue(), true) + ); + } + + /** + * Returns the effective {@code maximum} for the given schema as a {@link ResolvedMaxBound}, + * resolving through a top-level {@code $ref} and walking any {@code allOf} items. * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive - * (smallest) value wins. + * (smallest) value wins. When two bounds share the same value, the exclusive one wins. + * Both OAS 3.0 boolean {@code exclusiveMaximum} and OAS 3.1 numeric {@code exclusiveMaximum} + * are taken into account. * * @param openAPI the OpenAPI document used to resolve {@code $ref}s * @param schema the schema to inspect - * @return the effective maximum, or {@code null} if none is defined + * @return the effective maximum bound, or {@code null} if none is defined */ - public static BigDecimal resolveMaximum(OpenAPI openAPI, Schema schema) { + @Nullable + public static ResolvedMaxBound resolveMaximumBound(OpenAPI openAPI, Schema schema) { if (schema == null) return null; + if (schema.get$ref() != null) { schema = getReferencedSchema(openAPI, schema); - if (schema == null) return null; } - BigDecimal result = schema.getMaximum(); - if (schema.getAllOf() != null) { - for (Schema allOfItem : schema.getAllOf()) { - Schema resolved = getReferencedSchema(openAPI, allOfItem); - if (resolved != null && resolved.getMaximum() != null) { - if (result == null || resolved.getMaximum().compareTo(result) < 0) { - result = resolved.getMaximum(); - } - } - } - } - return result; + if (schema == null) return null; + + ResolvedMaxBound result = extractMaxBound(schema); + List allOf = schema.getAllOf(); + if (allOf == null) return result; + + return allOf.stream() + .map(allOfItem -> getReferencedSchema(openAPI, allOfItem)) + .filter(Objects::nonNull) + .map(ModelUtils::extractMaxBound) + .reduce(result, ResolvedMaxBound::getSmallerMaxBound); } /** - * Returns the effective {@code minimum} for the given schema, resolving through a top-level - * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). + * Returns the effective {@code minimum} for the given schema as a {@link ResolvedMinBound}, + * resolving through a top-level {@code $ref} and walking any {@code allOf} items. * Per JSON Schema / OpenAPI {@code allOf} intersection semantics the most restrictive - * (largest) value wins. + * (largest) value wins. When two bounds share the same value, the exclusive one wins. + * Both OAS 3.0 boolean {@code exclusiveMinimum} and OAS 3.1 numeric {@code exclusiveMinimum} + * are taken into account. * * @param openAPI the OpenAPI document used to resolve {@code $ref}s * @param schema the schema to inspect - * @return the effective minimum, or {@code null} if none is defined + * @return the effective minimum bound, or {@code null} if none is defined */ - public static BigDecimal resolveMinimum(OpenAPI openAPI, Schema schema) { + @Nullable + public static ResolvedMinBound resolveMinimumBound(OpenAPI openAPI, Schema schema) { if (schema == null) return null; + if (schema.get$ref() != null) { schema = getReferencedSchema(openAPI, schema); - if (schema == null) return null; - } - BigDecimal result = schema.getMinimum(); - if (schema.getAllOf() != null) { - for (Schema allOfItem : schema.getAllOf()) { - Schema resolved = getReferencedSchema(openAPI, allOfItem); - if (resolved != null && resolved.getMinimum() != null) { - if (result == null || resolved.getMinimum().compareTo(result) > 0) { - result = resolved.getMinimum(); - } - } - } } - return result; + if (schema == null) return null; + + ResolvedMinBound result = extractMinBound(schema); + List allOf = schema.getAllOf(); + if (allOf == null) return result; + + return allOf.stream() + .map(allOfItem -> getReferencedSchema(openAPI, allOfItem)) + .filter(Objects::nonNull) + .map(ModelUtils::extractMinBound) + .reduce(result, ResolvedMinBound::getLargerMinBound); } /** @@ -923,21 +1043,32 @@ public static BigDecimal resolveMinimum(OpenAPI openAPI, Schema schema) { * @return the effective default value, or {@code null} if none is defined */ public static Object resolveDefault(OpenAPI openAPI, Schema schema) { - if (schema == null) return null; - if (schema.get$ref() != null) { - schema = getReferencedSchema(openAPI, schema); - if (schema == null) return null; + if (schema == null) { + return null; } - if (schema.getDefault() != null) return schema.getDefault(); - if (schema.getAllOf() != null) { - for (Schema allOfItem : schema.getAllOf()) { - Schema resolved = getReferencedSchema(openAPI, allOfItem); - if (resolved != null && resolved.getDefault() != null) { - return resolved.getDefault(); - } - } + + Schema resolvedSchema = getReferencedSchema(openAPI, schema); + if (resolvedSchema == null) { + return null; } - return null; + + Object defaultValue = resolvedSchema.getDefault(); + if (defaultValue != null) { + return defaultValue; + } + + List allOf = resolvedSchema.getAllOf(); + if (allOf == null) { + return null; + } + + return allOf.stream() + .map(item -> getReferencedSchema(openAPI, item)) + .filter(Objects::nonNull) + .map(Schema::getDefault) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); } public static boolean hasValidation(Schema sc) { @@ -2406,8 +2537,8 @@ public static Schema cloneSchema(Schema schema, boolean openapi31) { /** * Simplifies the schema by removing the oneOfAnyOf if the oneOfAnyOf only contains a single non-null sub-schema * - * @param openAPI OpenAPI - * @param schema Schema + * @param openAPI OpenAPI + * @param schema Schema * @param subSchemas The oneOf or AnyOf schemas * @return The simplified schema */ @@ -2551,8 +2682,8 @@ public static boolean isUnsupportedSchema(OpenAPI openAPI, Schema schema) { /** * Copy meta data (e.g. description, default, examples, etc) from one schema to another. * - * @param from From schema - * @param to To schema + * @param from From schema + * @param to To schema */ public static void copyMetadata(Schema from, Schema to) { if (from.getDescription() != null) { @@ -2632,8 +2763,9 @@ public static boolean isMetadataOnlySchema(Schema schema) { /** * Returns true if the OpenAPI specification contains any schemas which are enums. - * @param openAPI OpenAPI specification - * @return true if the OpenAPI specification contains any schemas which are enums. + * + * @param openAPI OpenAPI specification + * @return true if the OpenAPI specification contains any schemas which are enums. */ public static boolean containsEnums(OpenAPI openAPI) { Map schemaMap = getSchemas(openAPI); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java index 6a27ccb216a7..a2e7136a8259 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java @@ -5,11 +5,13 @@ import io.swagger.v3.oas.models.PathItem; import io.swagger.v3.oas.models.Paths; import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.IntegerSchema; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.media.StringSchema; import io.swagger.v3.oas.models.parameters.Parameter; import org.testng.annotations.Test; +import java.math.BigDecimal; import java.util.List; import java.util.Map; @@ -25,6 +27,128 @@ public class SpringPageableScanUtilsTest { // Helpers // ------------------------------------------------------------------------- + /** + * Builds an OpenAPI doc with a single GET /items operation marked x-spring-paginated, + * accepting an arbitrary list of parameters. + */ + private static OpenAPI buildPageableOperationWithParams(List params) { + Operation op = new Operation(); + op.setOperationId("listItems"); + op.addExtension("x-spring-paginated", true); + params.forEach(op::addParametersItem); + + PathItem pathItem = new PathItem(); + pathItem.setGet(op); + + Paths paths = new Paths(); + paths.addPathItem("/items", pathItem); + + OpenAPI openAPI = new OpenAPI(); + openAPI.setPaths(paths); + return openAPI; + } + + // ------------------------------------------------------------------------- + // scanPageableConstraints — inclusive bounds (baseline) + // ------------------------------------------------------------------------- + + @Test + public void scanPageableConstraints_inclusiveBounds_usedDirectly() { + Schema pageSchema = new IntegerSchema(); + pageSchema.setMaximum(BigDecimal.valueOf(100)); + pageSchema.setMinimum(BigDecimal.valueOf(0)); + + Schema sizeSchema = new IntegerSchema(); + sizeSchema.setMaximum(BigDecimal.valueOf(50)); + sizeSchema.setMinimum(BigDecimal.valueOf(1)); + + OpenAPI openAPI = buildPageableOperationWithParams(List.of( + new Parameter().name("page").schema(pageSchema), + new Parameter().name("size").schema(sizeSchema) + )); + + Map result = + SpringPageableScanUtils.scanPageableConstraints(openAPI, false); + + assertThat(result).containsKey("listItems"); + SpringPageableScanUtils.PageableConstraintsData data = result.get("listItems"); + assertThat(data.maxPage).isEqualTo(100); + assertThat(data.minPage).isEqualTo(0); + assertThat(data.maxSize).isEqualTo(50); + assertThat(data.minSize).isEqualTo(1); + } + + // ------------------------------------------------------------------------- + // scanPageableConstraints — exclusive bounds + // ------------------------------------------------------------------------- + + @Test + public void scanPageableConstraints_exclusiveMaximum_subtractsOne() { + Schema pageSchema = new IntegerSchema(); + pageSchema.setMaximum(BigDecimal.valueOf(101)); + pageSchema.setExclusiveMaximum(Boolean.TRUE); // exclusive 101 → effective max = 100 + + Schema sizeSchema = new IntegerSchema(); + sizeSchema.setMaximum(BigDecimal.valueOf(51)); + sizeSchema.setExclusiveMaximum(Boolean.TRUE); // exclusive 51 → effective max = 50 + + OpenAPI openAPI = buildPageableOperationWithParams(List.of( + new Parameter().name("page").schema(pageSchema), + new Parameter().name("size").schema(sizeSchema) + )); + + Map result = + SpringPageableScanUtils.scanPageableConstraints(openAPI, false); + + assertThat(result).containsKey("listItems"); + SpringPageableScanUtils.PageableConstraintsData data = result.get("listItems"); + assertThat(data.maxPage).isEqualTo(100); + assertThat(data.maxSize).isEqualTo(50); + } + + @Test + public void scanPageableConstraints_exclusiveMinimum_addsOne() { + Schema pageSchema = new IntegerSchema(); + pageSchema.setMinimum(BigDecimal.valueOf(-1)); + pageSchema.setExclusiveMinimum(Boolean.TRUE); // exclusive -1 → effective min = 0 + + Schema sizeSchema = new IntegerSchema(); + sizeSchema.setMinimum(BigDecimal.valueOf(0)); + sizeSchema.setExclusiveMinimum(Boolean.TRUE); // exclusive 0 → effective min = 1 + + OpenAPI openAPI = buildPageableOperationWithParams(List.of( + new Parameter().name("page").schema(pageSchema), + new Parameter().name("size").schema(sizeSchema) + )); + + Map result = + SpringPageableScanUtils.scanPageableConstraints(openAPI, false); + + assertThat(result).containsKey("listItems"); + SpringPageableScanUtils.PageableConstraintsData data = result.get("listItems"); + assertThat(data.minPage).isEqualTo(0); + assertThat(data.minSize).isEqualTo(1); + } + + @Test + public void scanPageableConstraints_oas31NumericExclusive_subtractsOrAddsOne() { + Schema sizeSchema = new IntegerSchema(); + sizeSchema.setExclusiveMaximumValue(BigDecimal.valueOf(51)); // exclusive → effective max = 50 + sizeSchema.setExclusiveMinimumValue(BigDecimal.valueOf(0)); // exclusive → effective min = 1 + + OpenAPI openAPI = buildPageableOperationWithParams(List.of( + new Parameter().name("size").schema(sizeSchema) + )); + + Map result = + SpringPageableScanUtils.scanPageableConstraints(openAPI, false); + + assertThat(result).containsKey("listItems"); + SpringPageableScanUtils.PageableConstraintsData data = result.get("listItems"); + assertThat(data.maxSize).isEqualTo(50); + assertThat(data.minSize).isEqualTo(1); + } + /** * Builds an OpenAPI doc with a single GET /items operation marked x-spring-paginated. */ diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java index a445a02391b8..0acb4d4a7e94 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java @@ -759,44 +759,110 @@ public void getParentNameMultipleInterfacesTest() { } // ------------------------------------------------------------------------- - // resolveMaximum + // resolveMaximumBound // ------------------------------------------------------------------------- @Test - public void resolveMaximum_nullSchema_returnsNull() { - assertNull(ModelUtils.resolveMaximum(new OpenAPI(), null)); + public void resolveMaximumBound_nullSchema_returnsNull() { + assertNull(ModelUtils.resolveMaximumBound(new OpenAPI(), null)); } @Test - public void resolveMaximum_noMaximumDefined_returnsNull() { + public void resolveMaximumBound_noMaximumDefined_returnsNull() { OpenAPI openAPI = TestUtils.createOpenAPI(); Schema schema = new IntegerSchema(); - assertNull(ModelUtils.resolveMaximum(openAPI, schema)); + assertNull(ModelUtils.resolveMaximumBound(openAPI, schema)); } @Test - public void resolveMaximum_inlineMaximum_returnsIt() { + public void resolveMaximumBound_inclusiveMaximum_returnsInclusiveBound() { OpenAPI openAPI = TestUtils.createOpenAPI(); Schema schema = new IntegerSchema(); schema.setMaximum(BigDecimal.valueOf(100)); - assertEquals(ModelUtils.resolveMaximum(openAPI, schema), BigDecimal.valueOf(100)); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(100), bound.maxBound); + assertFalse(bound.exclusive); } @Test - public void resolveMaximum_refToSchemaWithMaximum_resolvesRef() { + public void resolveMaximumBound_exclusiveMaximum_returnsExclusiveBound() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMaximum(BigDecimal.valueOf(50)); + schema.setExclusiveMaximum(Boolean.TRUE); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(50), bound.maxBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMaximumBound_oas31NumericExclusive_returnsExclusiveBound() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setExclusiveMaximumValue(BigDecimal.valueOf(10)); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(10), bound.maxBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMaximumBound_oas31NumericExclusiveStricterThanInclusive_exclusiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMaximum(BigDecimal.valueOf(100)); // inclusive 100 + schema.setExclusiveMaximumValue(BigDecimal.valueOf(80)); // exclusive 80 is stricter + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(80), bound.maxBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMaximumBound_oas31NumericExclusiveLooseThanInclusive_inclusiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMaximum(BigDecimal.valueOf(50)); // inclusive 50 is stricter + schema.setExclusiveMaximumValue(BigDecimal.valueOf(90)); // exclusive 90 is looser + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(50), bound.maxBound); + assertFalse(bound.exclusive); + } + + @Test + public void resolveMaximumBound_sameValueInclusiveAndExclusive_exclusiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // maximum=50 (inclusive) + exclusiveMaximumValue=50 → exclusive 50 is stricter + Schema schema = new IntegerSchema(); + schema.setMaximum(BigDecimal.valueOf(50)); + schema.setExclusiveMaximumValue(BigDecimal.valueOf(50)); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(50), bound.maxBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMaximumBound_refToSchemaWithMaximum_resolvesRef() { OpenAPI openAPI = TestUtils.createOpenAPI(); Schema refTarget = new IntegerSchema(); refTarget.setMaximum(BigDecimal.valueOf(50)); + refTarget.setExclusiveMaximum(Boolean.TRUE); openAPI.getComponents().addSchemas("MyInt", refTarget); Schema ref = new Schema<>().$ref("#/components/schemas/MyInt"); - assertEquals(ModelUtils.resolveMaximum(openAPI, ref), BigDecimal.valueOf(50)); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, ref); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(50), bound.maxBound); + assertTrue(bound.exclusive); } @Test - public void resolveMaximum_allOf_returnsMostRestrictive() { + public void resolveMaximumBound_allOf_returnsMostRestrictive() { OpenAPI openAPI = TestUtils.createOpenAPI(); - // allOf item with max=200 and item with max=50 — 50 should win Schema loose = new IntegerSchema(); loose.setMaximum(BigDecimal.valueOf(200)); openAPI.getComponents().addSchemas("Loose", loose); @@ -809,13 +875,38 @@ public void resolveMaximum_allOf_returnsMostRestrictive() { new Schema<>().$ref("#/components/schemas/Loose"), new Schema<>().$ref("#/components/schemas/Strict") )); - assertEquals(ModelUtils.resolveMaximum(openAPI, schema), BigDecimal.valueOf(50)); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(50), bound.maxBound); + assertFalse(bound.exclusive); } @Test - public void resolveMaximum_inlineAndAllOf_mostRestrictiveWins() { + public void resolveMaximumBound_allOfSameValueDifferentExclusivity_exclusiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // allOf: max=50 inclusive vs max=50 exclusive — exclusive is stricter + Schema inclusive = new IntegerSchema(); + inclusive.setMaximum(BigDecimal.valueOf(50)); + openAPI.getComponents().addSchemas("Inclusive", inclusive); + + Schema exclusive = new IntegerSchema(); + exclusive.setMaximum(BigDecimal.valueOf(50)); + exclusive.setExclusiveMaximum(Boolean.TRUE); + openAPI.getComponents().addSchemas("Exclusive", exclusive); + + Schema schema = new Schema<>().allOf(Arrays.asList( + new Schema<>().$ref("#/components/schemas/Inclusive"), + new Schema<>().$ref("#/components/schemas/Exclusive") + )); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(50), bound.maxBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMaximumBound_inlineAndAllOf_mostRestrictiveWins() { OpenAPI openAPI = TestUtils.createOpenAPI(); - // allOf item has maximum=30, which is more restrictive than inline maximum=100 Schema allOfItem = new IntegerSchema(); allOfItem.setMaximum(BigDecimal.valueOf(30)); openAPI.getComponents().addSchemas("Base", allOfItem); @@ -823,56 +914,122 @@ public void resolveMaximum_inlineAndAllOf_mostRestrictiveWins() { Schema schema = new IntegerSchema(); schema.setMaximum(BigDecimal.valueOf(100)); schema.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); - assertEquals(ModelUtils.resolveMaximum(openAPI, schema), BigDecimal.valueOf(30)); + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(30), bound.maxBound); } @Test - public void resolveMaximum_allOfItemWithoutMaximum_ignored() { + public void resolveMaximumBound_allOfItemWithoutMaximum_ignored() { OpenAPI openAPI = TestUtils.createOpenAPI(); - openAPI.getComponents().addSchemas("NoMax", new IntegerSchema()); // no maximum + openAPI.getComponents().addSchemas("NoMax", new IntegerSchema()); Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/NoMax"))); - assertNull(ModelUtils.resolveMaximum(openAPI, schema)); + assertNull(ModelUtils.resolveMaximumBound(openAPI, schema)); } // ------------------------------------------------------------------------- - // resolveMinimum + // resolveMinimumBound // ------------------------------------------------------------------------- @Test - public void resolveMinimum_nullSchema_returnsNull() { - assertNull(ModelUtils.resolveMinimum(new OpenAPI(), null)); + public void resolveMinimumBound_nullSchema_returnsNull() { + assertNull(ModelUtils.resolveMinimumBound(new OpenAPI(), null)); } @Test - public void resolveMinimum_noMinimumDefined_returnsNull() { + public void resolveMinimumBound_noMinimumDefined_returnsNull() { OpenAPI openAPI = TestUtils.createOpenAPI(); - assertNull(ModelUtils.resolveMinimum(openAPI, new IntegerSchema())); + assertNull(ModelUtils.resolveMinimumBound(openAPI, new IntegerSchema())); } @Test - public void resolveMinimum_inlineMinimum_returnsIt() { + public void resolveMinimumBound_inclusiveMinimum_returnsInclusiveBound() { OpenAPI openAPI = TestUtils.createOpenAPI(); Schema schema = new IntegerSchema(); schema.setMinimum(BigDecimal.valueOf(1)); - assertEquals(ModelUtils.resolveMinimum(openAPI, schema), BigDecimal.valueOf(1)); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(1), bound.minBound); + assertFalse(bound.exclusive); } @Test - public void resolveMinimum_refToSchemaWithMinimum_resolvesRef() { + public void resolveMinimumBound_exclusiveMinimum_returnsExclusiveBound() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMinimum(BigDecimal.valueOf(0)); + schema.setExclusiveMinimum(Boolean.TRUE); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(0), bound.minBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMinimumBound_oas31NumericExclusive_returnsExclusiveBound() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setExclusiveMinimumValue(BigDecimal.valueOf(5)); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(5), bound.minBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMinimumBound_oas31NumericExclusiveStricterThanInclusive_exclusiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMinimum(BigDecimal.valueOf(0)); // inclusive 0 + schema.setExclusiveMinimumValue(BigDecimal.valueOf(3)); // exclusive 3 is stricter + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(3), bound.minBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMinimumBound_oas31NumericExclusiveLooseThanInclusive_inclusiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMinimum(BigDecimal.valueOf(10)); // inclusive 10 is stricter + schema.setExclusiveMinimumValue(BigDecimal.valueOf(2)); // exclusive 2 is looser + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(10), bound.minBound); + assertFalse(bound.exclusive); + } + + @Test + public void resolveMinimumBound_sameValueInclusiveAndExclusive_exclusiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema schema = new IntegerSchema(); + schema.setMinimum(BigDecimal.valueOf(5)); + schema.setExclusiveMinimumValue(BigDecimal.valueOf(5)); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(5), bound.minBound); + assertTrue(bound.exclusive); + } + + @Test + public void resolveMinimumBound_refToSchemaWithMinimum_resolvesRef() { OpenAPI openAPI = TestUtils.createOpenAPI(); Schema refTarget = new IntegerSchema(); refTarget.setMinimum(BigDecimal.valueOf(5)); openAPI.getComponents().addSchemas("MyInt", refTarget); Schema ref = new Schema<>().$ref("#/components/schemas/MyInt"); - assertEquals(ModelUtils.resolveMinimum(openAPI, ref), BigDecimal.valueOf(5)); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, ref); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(5), bound.minBound); + assertFalse(bound.exclusive); } @Test - public void resolveMinimum_allOf_returnsMostRestrictive() { + public void resolveMinimumBound_allOf_returnsMostRestrictive() { OpenAPI openAPI = TestUtils.createOpenAPI(); - // allOf item with min=1 and item with min=10 — 10 should win (larger = more restrictive lower bound) Schema permissive = new IntegerSchema(); permissive.setMinimum(BigDecimal.valueOf(1)); openAPI.getComponents().addSchemas("Permissive", permissive); @@ -885,13 +1042,38 @@ public void resolveMinimum_allOf_returnsMostRestrictive() { new Schema<>().$ref("#/components/schemas/Permissive"), new Schema<>().$ref("#/components/schemas/Strict") )); - assertEquals(ModelUtils.resolveMinimum(openAPI, schema), BigDecimal.valueOf(10)); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(10), bound.minBound); + assertFalse(bound.exclusive); + } + + @Test + public void resolveMinimumBound_allOfSameValueDifferentExclusivity_exclusiveWins() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // allOf: min=5 inclusive vs min=5 exclusive — exclusive is stricter + Schema inclusive = new IntegerSchema(); + inclusive.setMinimum(BigDecimal.valueOf(5)); + openAPI.getComponents().addSchemas("Inclusive", inclusive); + + Schema exclusive = new IntegerSchema(); + exclusive.setMinimum(BigDecimal.valueOf(5)); + exclusive.setExclusiveMinimum(Boolean.TRUE); + openAPI.getComponents().addSchemas("Exclusive", exclusive); + + Schema schema = new Schema<>().allOf(Arrays.asList( + new Schema<>().$ref("#/components/schemas/Inclusive"), + new Schema<>().$ref("#/components/schemas/Exclusive") + )); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(5), bound.minBound); + assertTrue(bound.exclusive); } @Test - public void resolveMinimum_inlineAndAllOf_mostRestrictiveWins() { + public void resolveMinimumBound_inlineAndAllOf_mostRestrictiveWins() { OpenAPI openAPI = TestUtils.createOpenAPI(); - // allOf item has minimum=20, which is more restrictive than inline minimum=0 Schema allOfItem = new IntegerSchema(); allOfItem.setMinimum(BigDecimal.valueOf(20)); openAPI.getComponents().addSchemas("Base", allOfItem); @@ -899,16 +1081,18 @@ public void resolveMinimum_inlineAndAllOf_mostRestrictiveWins() { Schema schema = new IntegerSchema(); schema.setMinimum(BigDecimal.valueOf(0)); schema.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); - assertEquals(ModelUtils.resolveMinimum(openAPI, schema), BigDecimal.valueOf(20)); + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, schema); + assertNotNull(bound); + assertEquals(BigDecimal.valueOf(20), bound.minBound); } @Test - public void resolveMinimum_allOfItemWithoutMinimum_ignored() { + public void resolveMinimumBound_allOfItemWithoutMinimum_ignored() { OpenAPI openAPI = TestUtils.createOpenAPI(); openAPI.getComponents().addSchemas("NoMin", new IntegerSchema()); Schema schema = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/NoMin"))); - assertNull(ModelUtils.resolveMinimum(openAPI, schema)); + assertNull(ModelUtils.resolveMinimumBound(openAPI, schema)); } // ------------------------------------------------------------------------- From 86992bf4cb81ab3a255b151c982bdd5b1806a08a Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 7 May 2026 13:24:33 +0200 Subject: [PATCH 11/21] DRY code --- .../codegen/utils/ModelUtils.java | 79 +++++++------------ 1 file changed, 30 insertions(+), 49 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index 89fc6e43ebf6..37e72a6dfa16 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -720,7 +720,7 @@ public static boolean isDateTimeSchema(Schema schema) { public static boolean isDateTimeLocalSchema(Schema schema) { // format: date-time-local, see https://spec.openapis.org/registry/format/date-time-local.html return (SchemaTypeUtil.STRING_TYPE.equals(getType(schema)) - && "date-time-local".equals(schema.getFormat())); + && "date-time-local".equals(schema.getFormat())); } public static boolean isTimeLocalSchema(Schema schema) { @@ -982,22 +982,17 @@ private static ResolvedMinBound extractMinBound(Schema schema) { */ @Nullable public static ResolvedMaxBound resolveMaximumBound(OpenAPI openAPI, Schema schema) { - if (schema == null) return null; - - if (schema.get$ref() != null) { - schema = getReferencedSchema(openAPI, schema); - } + schema = getReferencedSchema(openAPI, schema); if (schema == null) return null; ResolvedMaxBound result = extractMaxBound(schema); - List allOf = schema.getAllOf(); - if (allOf == null) return result; - - return allOf.stream() - .map(allOfItem -> getReferencedSchema(openAPI, allOfItem)) - .filter(Objects::nonNull) - .map(ModelUtils::extractMaxBound) - .reduce(result, ResolvedMaxBound::getSmallerMaxBound); + return !hasAllOf(schema) + ? result + : schema.getAllOf().stream() + .map(allOfItem -> getReferencedSchema(openAPI, allOfItem)) + .filter(Objects::nonNull) + .map(ModelUtils::extractMaxBound) + .reduce(result, ResolvedMaxBound::getSmallerMaxBound); } /** @@ -1014,22 +1009,17 @@ public static ResolvedMaxBound resolveMaximumBound(OpenAPI openAPI, Schema sc */ @Nullable public static ResolvedMinBound resolveMinimumBound(OpenAPI openAPI, Schema schema) { - if (schema == null) return null; - - if (schema.get$ref() != null) { - schema = getReferencedSchema(openAPI, schema); - } + schema = getReferencedSchema(openAPI, schema); if (schema == null) return null; ResolvedMinBound result = extractMinBound(schema); - List allOf = schema.getAllOf(); - if (allOf == null) return result; - - return allOf.stream() - .map(allOfItem -> getReferencedSchema(openAPI, allOfItem)) - .filter(Objects::nonNull) - .map(ModelUtils::extractMinBound) - .reduce(result, ResolvedMinBound::getLargerMinBound); + return !hasAllOf(schema) + ? result + : schema.getAllOf().stream() + .map(allOfItem -> getReferencedSchema(openAPI, allOfItem)) + .filter(Objects::nonNull) + .map(ModelUtils::extractMinBound) + .reduce(result, ResolvedMinBound::getLargerMinBound); } /** @@ -1043,32 +1033,23 @@ public static ResolvedMinBound resolveMinimumBound(OpenAPI openAPI, Schema sc * @return the effective default value, or {@code null} if none is defined */ public static Object resolveDefault(OpenAPI openAPI, Schema schema) { - if (schema == null) { - return null; - } - - Schema resolvedSchema = getReferencedSchema(openAPI, schema); - if (resolvedSchema == null) { - return null; - } + schema = getReferencedSchema(openAPI, schema); + if (schema == null) return null; - Object defaultValue = resolvedSchema.getDefault(); + Object defaultValue = schema.getDefault(); if (defaultValue != null) { + // inline default value takes precedence return defaultValue; } - - List allOf = resolvedSchema.getAllOf(); - if (allOf == null) { - return null; - } - - return allOf.stream() - .map(item -> getReferencedSchema(openAPI, item)) - .filter(Objects::nonNull) - .map(Schema::getDefault) - .filter(Objects::nonNull) - .findFirst() - .orElse(null); + return !hasAllOf(schema) + ? null + : schema.getAllOf().stream() + .map(item -> getReferencedSchema(openAPI, item)) + .filter(Objects::nonNull) + .map(Schema::getDefault) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); } public static boolean hasValidation(Schema sc) { From 25f1f8fc411ce41458e934cf2344f1d15230b63a Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 7 May 2026 13:49:51 +0200 Subject: [PATCH 12/21] refactor: improve resolution of nested allOf constraints for maximum and minimum bounds --- .../codegen/utils/ModelUtils.java | 10 +-- .../codegen/utils/ModelUtilsTest.java | 86 +++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index 37e72a6dfa16..7e2c474edf2b 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -989,9 +989,8 @@ public static ResolvedMaxBound resolveMaximumBound(OpenAPI openAPI, Schema sc return !hasAllOf(schema) ? result : schema.getAllOf().stream() - .map(allOfItem -> getReferencedSchema(openAPI, allOfItem)) - .filter(Objects::nonNull) - .map(ModelUtils::extractMaxBound) + // recursive search for smallest max bound + .map(allOfItem -> resolveMaximumBound(openAPI, allOfItem)) .reduce(result, ResolvedMaxBound::getSmallerMaxBound); } @@ -1016,9 +1015,8 @@ public static ResolvedMinBound resolveMinimumBound(OpenAPI openAPI, Schema sc return !hasAllOf(schema) ? result : schema.getAllOf().stream() - .map(allOfItem -> getReferencedSchema(openAPI, allOfItem)) - .filter(Objects::nonNull) - .map(ModelUtils::extractMinBound) + // recursive search for largest min bound + .map(allOfItem -> resolveMinimumBound(openAPI, allOfItem)) .reduce(result, ResolvedMinBound::getLargerMinBound); } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java index 0acb4d4a7e94..386b2307b6ce 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java @@ -928,6 +928,49 @@ public void resolveMaximumBound_allOfItemWithoutMaximum_ignored() { assertNull(ModelUtils.resolveMaximumBound(openAPI, schema)); } + @Test + public void resolveMaximumBound_nestedAllOf_recurses() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // Grandparent defines maximum=50 + Schema grandparent = new IntegerSchema(); + grandparent.setMaximum(BigDecimal.valueOf(50)); + openAPI.getComponents().addSchemas("Grandparent", grandparent); + + // Parent has allOf → Grandparent (no direct maximum) + Schema parent = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Grandparent"))); + openAPI.getComponents().addSchemas("Parent", parent); + + // Child has allOf → Parent + Schema child = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Parent"))); + + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, child); + assertNotNull(bound); + assertEquals(bound.maxBound, BigDecimal.valueOf(50)); + assertFalse(bound.exclusive); + } + + @Test + public void resolveMaximumBound_nestedAllOf_mostRestrictiveAcrossAllLevels() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // Grandparent defines maximum=30 (stricter) + Schema grandparent = new IntegerSchema(); + grandparent.setMaximum(BigDecimal.valueOf(30)); + openAPI.getComponents().addSchemas("Grandparent", grandparent); + + // Parent defines maximum=100 and also allOf → Grandparent + Schema parent = new IntegerSchema(); + parent.setMaximum(BigDecimal.valueOf(100)); + parent.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Grandparent"))); + openAPI.getComponents().addSchemas("Parent", parent); + + // Child allOf → Parent + Schema child = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Parent"))); + + ModelUtils.ResolvedMaxBound bound = ModelUtils.resolveMaximumBound(openAPI, child); + assertNotNull(bound); + assertEquals(bound.maxBound, BigDecimal.valueOf(30)); + } + // ------------------------------------------------------------------------- // resolveMinimumBound // ------------------------------------------------------------------------- @@ -1095,6 +1138,49 @@ public void resolveMinimumBound_allOfItemWithoutMinimum_ignored() { assertNull(ModelUtils.resolveMinimumBound(openAPI, schema)); } + @Test + public void resolveMinimumBound_nestedAllOf_recurses() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // Grandparent defines minimum=10 + Schema grandparent = new IntegerSchema(); + grandparent.setMinimum(BigDecimal.valueOf(10)); + openAPI.getComponents().addSchemas("Grandparent", grandparent); + + // Parent has allOf → Grandparent (no direct minimum) + Schema parent = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Grandparent"))); + openAPI.getComponents().addSchemas("Parent", parent); + + // Child has allOf → Parent + Schema child = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Parent"))); + + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, child); + assertNotNull(bound); + assertEquals(bound.minBound, BigDecimal.valueOf(10)); + assertFalse(bound.exclusive); + } + + @Test + public void resolveMinimumBound_nestedAllOf_mostRestrictiveAcrossAllLevels() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // Grandparent defines minimum=20 (stricter) + Schema grandparent = new IntegerSchema(); + grandparent.setMinimum(BigDecimal.valueOf(20)); + openAPI.getComponents().addSchemas("Grandparent", grandparent); + + // Parent defines minimum=5 and also allOf → Grandparent + Schema parent = new IntegerSchema(); + parent.setMinimum(BigDecimal.valueOf(5)); + parent.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/Grandparent"))); + openAPI.getComponents().addSchemas("Parent", parent); + + // Child allOf → Parent + Schema child = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Parent"))); + + ModelUtils.ResolvedMinBound bound = ModelUtils.resolveMinimumBound(openAPI, child); + assertNotNull(bound); + assertEquals(bound.minBound, BigDecimal.valueOf(20)); + } + // ------------------------------------------------------------------------- // resolveDefault // ------------------------------------------------------------------------- From 4c7032c60c16d642dce903e349df764675b6030e Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 7 May 2026 14:02:15 +0200 Subject: [PATCH 13/21] re-enable test --- .../codegen/kotlin/spring/KotlinSpringServerCodegenTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index 0e3f0c4c5b1b..243c20c62317 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -6013,6 +6013,7 @@ public void substituteGenericPagedModel_withModelNameSuffix_suppressesPagedSchem } + @Test(description = "oneOf with discriminator generates thin sealed interface with Jackson annotations") public void testOneOfWithDiscriminatorGeneratesThinInterface() throws IOException { File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); output.deleteOnExit(); From ea9937a002a2f793734a4d1a152314e61052cffb Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 7 May 2026 19:03:00 +0200 Subject: [PATCH 14/21] test: fix implementation and add unit test for resolving default in nested allOf schemas --- .../codegen/utils/ModelUtils.java | 40 ++++++++++++------- .../codegen/utils/ModelUtilsTest.java | 16 ++++++++ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index 7e2c474edf2b..05547d317746 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -583,7 +583,7 @@ public static boolean isMapSchema(Schema schema) { // additionalProperties explicitly set to false if ((schema.getAdditionalProperties() instanceof Boolean && Boolean.FALSE.equals(schema.getAdditionalProperties())) || - (schema.getAdditionalProperties() instanceof Schema && Boolean.FALSE.equals(((Schema) schema.getAdditionalProperties()).getBooleanSchemaValue())) + (schema.getAdditionalProperties() instanceof Schema && Boolean.FALSE.equals(((Schema) schema.getAdditionalProperties()).getBooleanSchemaValue())) ) { return false; } @@ -726,7 +726,7 @@ public static boolean isDateTimeLocalSchema(Schema schema) { public static boolean isTimeLocalSchema(Schema schema) { // format: time-local, see https://spec.openapis.org/registry/format/time-local.html return (SchemaTypeUtil.STRING_TYPE.equals(getType(schema)) - && "time-local".equals(schema.getFormat())); + && "time-local".equals(schema.getFormat())); } public static boolean isPasswordSchema(Schema schema) { @@ -845,12 +845,12 @@ public static boolean isModelWithPropertiesOnly(Schema schema) { (null != schema.getProperties() && !schema.getProperties().isEmpty()) && // no additionalProperties is set (schema.getAdditionalProperties() == null || - // additionalProperties is boolean and set to false - (schema.getAdditionalProperties() instanceof Boolean && !(Boolean) schema.getAdditionalProperties()) || - // additionalProperties is a schema with its boolean value set to false - (schema.getAdditionalProperties() instanceof Schema && - ((Schema) schema.getAdditionalProperties()).getBooleanSchemaValue() != null && - !((Schema) schema.getAdditionalProperties()).getBooleanSchemaValue()) + // additionalProperties is boolean and set to false + (schema.getAdditionalProperties() instanceof Boolean && !(Boolean) schema.getAdditionalProperties()) || + // additionalProperties is a schema with its boolean value set to false + (schema.getAdditionalProperties() instanceof Schema && + ((Schema) schema.getAdditionalProperties()).getBooleanSchemaValue() != null && + !((Schema) schema.getAdditionalProperties()).getBooleanSchemaValue()) ); } @@ -989,7 +989,7 @@ public static ResolvedMaxBound resolveMaximumBound(OpenAPI openAPI, Schema sc return !hasAllOf(schema) ? result : schema.getAllOf().stream() - // recursive search for smallest max bound + // recursive search for smallest max bound .map(allOfItem -> resolveMaximumBound(openAPI, allOfItem)) .reduce(result, ResolvedMaxBound::getSmallerMaxBound); } @@ -1022,9 +1022,20 @@ public static ResolvedMinBound resolveMinimumBound(OpenAPI openAPI, Schema sc /** * Returns the effective {@code default} for the given schema, resolving through a top-level - * {@code $ref} and walking any {@code allOf} items (each resolved via their own {@code $ref}). - * Unlike constraints, the inline schema's default takes precedence (explicit per-endpoint - * override); falls back to the first non-null default found in {@code allOf} items. + * {@code $ref} and recursively walking any {@code allOf} items. + * The inline schema's default takes precedence (explicit per-endpoint override); + * falls back to the first non-null default found via depth-first search of {@code allOf} items. + * Circular {@code allOf} references are detected and skipped. + * + * @param openAPI the OpenAPI document used to resolve {@code $ref}s + * @param schema the schema to inspect + * @return the effective default value, or {@code null} if none is defined + */ + /** + * Returns the effective {@code default} for the given schema, resolving through a top-level + * {@code $ref} and recursively walking any {@code allOf} items. + * The inline schema's default takes precedence (explicit per-endpoint override); + * falls back to the first non-null default found via depth-first search of {@code allOf} items. * * @param openAPI the OpenAPI document used to resolve {@code $ref}s * @param schema the schema to inspect @@ -1042,9 +1053,8 @@ public static Object resolveDefault(OpenAPI openAPI, Schema schema) { return !hasAllOf(schema) ? null : schema.getAllOf().stream() - .map(item -> getReferencedSchema(openAPI, item)) - .filter(Objects::nonNull) - .map(Schema::getDefault) + // recursive search for default + .map(item -> resolveDefault(openAPI, item)) .filter(Objects::nonNull) .findFirst() .orElse(null); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java index 386b2307b6ce..c9d45e598941 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java @@ -1257,4 +1257,20 @@ public void resolveDefault_stringDefault_returnsIt() { schema.setDefault("hello"); assertEquals(ModelUtils.resolveDefault(openAPI, schema), "hello"); } + + @Test + public void resolveDefault_nestedAllOf_findsDefaultInNestedItem() { + OpenAPI openAPI = TestUtils.createOpenAPI(); + // base has default=99; mid allOf → base; top allOf → mid + Schema base = new IntegerSchema(); + base.setDefault(99); + openAPI.getComponents().addSchemas("Base", base); + + Schema mid = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Base"))); + openAPI.getComponents().addSchemas("Mid", mid); + + Schema top = new Schema<>().allOf(List.of(new Schema<>().$ref("#/components/schemas/Mid"))); + + assertEquals(ModelUtils.resolveDefault(openAPI, top), 99); + } } From 4c76d10dbe88c294d4c4e99a81b152bbe8e4e02b Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Thu, 7 May 2026 19:19:10 +0200 Subject: [PATCH 15/21] add comment to explain that behavior around default is undefined in open api spec --- .../main/java/org/openapitools/codegen/utils/ModelUtils.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index 05547d317746..78fed854c3b3 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -1056,6 +1056,8 @@ public static Object resolveDefault(OpenAPI openAPI, Schema schema) { // recursive search for default .map(item -> resolveDefault(openAPI, item)) .filter(Objects::nonNull) + // first non-null default in allOf wins. + // This is very arbitrary and might not be correct behavior, since behavior regarding default inheritance/overriding is unspecified .findFirst() .orElse(null); } From 5e85d534049c5b67ba8eeeea81d9aa72ad6d1a97 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 15 May 2026 00:25:26 +0200 Subject: [PATCH 16/21] pageable-scan-resolve-allof-constraints refactor: streamline pageable handling and enhance annotation application logic --- .../languages/KotlinSpringServerCodegen.java | 136 +----- .../codegen/languages/SpringCodegen.java | 123 ++---- .../languages/SpringPageableScanUtils.java | 410 ++++++++++++++++-- .../codegen/utils/ModelUtils.java | 26 +- .../SpringPageableScanUtilsTest.java | 324 ++++++++++++++ .../org/openapitools/api/PetController.java | 2 - .../java/org/openapitools/api/PetApi.java | 14 +- 7 files changed, 782 insertions(+), 253 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index dbd241a68f5a..30843f29a948 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java @@ -197,20 +197,14 @@ public String getDescription() { private Map sealedInterfaceToOperationId = new HashMap<>(); private boolean sealedInterfacesFileWritten = false; - // Map from operationId to allowed sort values for @ValidSort annotation generation - private Map> sortValidationEnums = new HashMap<>(); - - // Map from operationId to pageable defaults for @PageableDefault/@SortDefault annotation generation - private Map pageableDefaultsRegistry = new HashMap<>(); - - // Map from operationId to pageable constraints for @ValidPageable annotation generation - private Map pageableConstraintsRegistry = new HashMap<>(); - // Map from schema name to detected paged-model info (populated when substituteGenericPagedModel=true) private Map pagedModelRegistry = new HashMap<>(); // Simple class name of the PagedModel substitute (derived from importMapping; defaults to "PagedModel") private String pagedModelClassName = "PagedModel"; + // Holds scan results for Spring Pageable features (populated during preprocessOpenAPI) + private final SpringPageableScanUtils pageableUtils = new SpringPageableScanUtils(); + public KotlinSpringServerCodegen() { super(); @@ -1038,34 +1032,16 @@ public void addOperationToGroup(String tag, String resourcePath, Operation opera */ @Override public CodegenOperation fromOperation(String path, String httpMethod, Operation operation, List servers) { - // #8315 Spring Data Web default query params recognized by Pageable - List defaultPageableQueryParams = Arrays.asList("page", "size", "sort"); + // Auto-detect pagination parameters and set x-spring-paginated if autoXSpringPaginated is enabled. + // Must be done BEFORE super.fromOperation() so that the base codegen populates + // codegenOperation.vendorExtensions from the extension we just set on 'operation'. + // Only for spring-boot library; respect manual x-spring-paginated: false override. + if (SPRING_BOOT.equals(library)) { + SpringPageableScanUtils.applyAutoXSpringPaginatedIfNeeded(operation, autoXSpringPaginated); + } CodegenOperation codegenOperation = super.fromOperation(path, httpMethod, operation, servers); - // Check if operation has all three pagination query parameters (case-sensitive) - boolean hasParamsForPageable = codegenOperation.queryParams.stream() - .map(p -> p.baseName) - .collect(Collectors.toSet()) - .containsAll(defaultPageableQueryParams); - // Auto-detect pagination parameters and add x-spring-paginated if autoXSpringPaginated is enabled - // Only for spring-boot library, respect manual x-spring-paginated: false setting - if (SPRING_BOOT.equals(library) && autoXSpringPaginated) { - // Check if x-spring-paginated is not explicitly set to false - if (operation.getExtensions() == null || !Boolean.FALSE.equals(operation.getExtensions().get("x-spring-paginated"))) { - - - if (hasParamsForPageable) { - // Automatically add x-spring-paginated to the operation - if (operation.getExtensions() == null) { - operation.setExtensions(new HashMap<>()); - } - operation.getExtensions().put("x-spring-paginated", Boolean.TRUE); - codegenOperation.vendorExtensions.put("x-spring-paginated", Boolean.TRUE); - } - } - } - // Only process x-spring-paginated for server-side libraries (spring-boot) // Client libraries (spring-cloud, spring-declarative-http-interface) need actual query parameters for HTTP requests if (SPRING_BOOT.equals(library)) { @@ -1078,75 +1054,16 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation // add org.springframework.data.domain.Pageable import when needed (server libraries only) if (operation.getExtensions() != null && Boolean.TRUE.equals(operation.getExtensions().get("x-spring-paginated"))) { codegenOperation.imports.add("Pageable"); - if (DocumentationProvider.SPRINGDOC.equals(getDocumentationProvider())) { - codegenOperation.imports.add("PageableAsQueryParam"); - // Prepend @PageableAsQueryParam to existing x-operation-extra-annotation if present - // Use getObjectAsStringList to properly handle both list and string formats: - // - YAML list: ['@Ann1', '@Ann2'] -> List of annotations - // - Single string: '@Ann1 @Ann2' -> Single-element list - // - Nothing/null -> Empty list - Object existingAnnotation = codegenOperation.vendorExtensions.get("x-operation-extra-annotation"); - List annotations = DefaultCodegen.getObjectAsStringList(existingAnnotation); - - // Prepend @PageableAsQueryParam to the beginning of the list - List updatedAnnotations = new ArrayList<>(); - updatedAnnotations.add("@PageableAsQueryParam"); - updatedAnnotations.addAll(annotations); - - codegenOperation.vendorExtensions.put("x-operation-extra-annotation", updatedAnnotations); - } + SpringPageableScanUtils.applySpringDocPageableAnnotation(codegenOperation, + SpringPageableScanUtils.AnnotationSyntax.KOTLIN, + DocumentationProvider.SPRINGDOC.equals(getDocumentationProvider())); // #8315 Remove matching Spring Data Web default query params if 'x-spring-paginated' with Pageable is used - // Build pageable parameter annotations (@ValidPageable, @ValidSort, @PageableDefault, @SortDefault.SortDefaults) - List pageableAnnotations = new ArrayList<>(); - - if (generatePageableConstraintValidation && useBeanValidation && pageableConstraintsRegistry.containsKey(codegenOperation.operationId)) { - SpringPageableScanUtils.PageableConstraintsData constraints = pageableConstraintsRegistry.get(codegenOperation.operationId); - List attrs = new ArrayList<>(); - if (constraints.maxSize >= 0) attrs.add("maxSize = " + constraints.maxSize); - if (constraints.maxPage >= 0) attrs.add("maxPage = " + constraints.maxPage); - if (constraints.minSize >= 0) attrs.add("minSize = " + constraints.minSize); - if (constraints.minPage >= 0) attrs.add("minPage = " + constraints.minPage); - pageableAnnotations.add("@ValidPageable(" + String.join(", ", attrs) + ")"); - codegenOperation.imports.add("ValidPageable"); - } - - if (generateSortValidation && useBeanValidation && sortValidationEnums.containsKey(codegenOperation.operationId)) { - List allowedSortValues = sortValidationEnums.get(codegenOperation.operationId); - String allowedValuesStr = allowedSortValues.stream() - .map(v -> "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\"") - .collect(Collectors.joining(", ")); - pageableAnnotations.add("@ValidSort(allowedValues = [" + allowedValuesStr + "])"); - codegenOperation.imports.add("ValidSort"); - } - - // Generate @PageableDefault / @SortDefault.SortDefaults annotations if defaults are present - if (pageableDefaultsRegistry.containsKey(codegenOperation.operationId)) { - SpringPageableScanUtils.PageableDefaultsData defaults = pageableDefaultsRegistry.get(codegenOperation.operationId); - - if (defaults.page != null || defaults.size != null) { - List attrs = new ArrayList<>(); - if (defaults.page != null) attrs.add("page = " + defaults.page); - if (defaults.size != null) attrs.add("size = " + defaults.size); - pageableAnnotations.add("@PageableDefault(" + String.join(", ", attrs) + ")"); - codegenOperation.imports.add("PageableDefault"); - } - - if (!defaults.sortDefaults.isEmpty()) { - List sortEntries = defaults.sortDefaults.stream() - .map(sf -> "SortDefault(sort = [\"" + sf.field + "\"], direction = Sort.Direction." + sf.direction + ")") - .collect(Collectors.toList()); - pageableAnnotations.add("@SortDefault.SortDefaults(" + String.join(", ", sortEntries) + ")"); - codegenOperation.imports.add("SortDefault"); - codegenOperation.imports.add("Sort"); - } - } - - if (!pageableAnnotations.isEmpty()) { - codegenOperation.vendorExtensions.put("x-pageable-extra-annotation", pageableAnnotations); - } - codegenOperation.queryParams.removeIf(param -> defaultPageableQueryParams.contains(param.baseName)); - codegenOperation.allParams.removeIf(param -> param.isQueryParam && defaultPageableQueryParams.contains(param.baseName)); + // Build and attach pageable parameter annotations + SpringPageableScanUtils.removePageableQueryParams(codegenOperation); + pageableUtils.applyPageableAnnotations(codegenOperation, + generatePageableConstraintValidation, useBeanValidation, + generateSortValidation, SpringPageableScanUtils.AnnotationSyntax.KOTLIN); } } @@ -1191,27 +1108,22 @@ public void preprocessOpenAPI(OpenAPI openAPI) { (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "EnumConverterConfiguration.kt")); } - if (SPRING_BOOT.equals(library) && generateSortValidation && useBeanValidation) { - sortValidationEnums = SpringPageableScanUtils.scanSortValidationEnums(openAPI, autoXSpringPaginated); - if (!sortValidationEnums.isEmpty()) { + if (SPRING_BOOT.equals(library)) { + pageableUtils.scanAll(openAPI, autoXSpringPaginated); + + if (generateSortValidation && useBeanValidation && !pageableUtils.sortValidationEnums.isEmpty()) { importMapping.putIfAbsent("ValidSort", configPackage + ".ValidSort"); supportingFiles.add(new SupportingFile("validSort.mustache", (sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidSort.kt")); } - } - if (SPRING_BOOT.equals(library)) { - pageableDefaultsRegistry = SpringPageableScanUtils.scanPageableDefaults(openAPI, autoXSpringPaginated); - if (!pageableDefaultsRegistry.isEmpty()) { + if (!pageableUtils.pageableDefaultsRegistry.isEmpty()) { importMapping.putIfAbsent("PageableDefault", "org.springframework.data.web.PageableDefault"); importMapping.putIfAbsent("SortDefault", "org.springframework.data.web.SortDefault"); importMapping.putIfAbsent("Sort", "org.springframework.data.domain.Sort"); } - } - if (SPRING_BOOT.equals(library) && generatePageableConstraintValidation && useBeanValidation) { - pageableConstraintsRegistry = SpringPageableScanUtils.scanPageableConstraints(openAPI, autoXSpringPaginated); - if (!pageableConstraintsRegistry.isEmpty()) { + if (generatePageableConstraintValidation && useBeanValidation && !pageableUtils.pageableConstraintsRegistry.isEmpty()) { importMapping.putIfAbsent("ValidPageable", configPackage + ".ValidPageable"); supportingFiles.add(new SupportingFile("validPageable.mustache", (sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidPageable.kt")); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java index d9952c925fca..b51115ef4fcd 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java @@ -197,17 +197,14 @@ public enum RequestMappingMode { @Setter protected boolean generatePageableConstraintValidation = false; @Setter protected boolean substituteGenericPagedModel = false; - // Map from operationId to allowed sort values for @ValidSort annotation generation - private Map> sortValidationEnums = new HashMap<>(); - // Map from operationId to pageable defaults for @PageableDefault/@SortDefault annotation generation - private Map pageableDefaultsRegistry = new HashMap<>(); - // Map from operationId to pageable constraints for @ValidPageable annotation generation - private Map pageableConstraintsRegistry = new HashMap<>(); // Map from schema name to detected paged-model info (populated when substituteGenericPagedModel=true) private Map pagedModelRegistry = new HashMap<>(); // Simple class name of the PagedModel substitute (derived from importMapping; defaults to "PagedModel") private String pagedModelClassName = "PagedModel"; + // Holds scan results for Spring Pageable features (populated during preprocessOpenAPI) + private final SpringPageableScanUtils pageableUtils = new SpringPageableScanUtils(); + public SpringCodegen() { super(); @@ -843,27 +840,22 @@ public void preprocessOpenAPI(OpenAPI openAPI) { (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "EnumConverterConfiguration.java")); } - if (SPRING_BOOT.equals(library) && generateSortValidation && useBeanValidation) { - sortValidationEnums = SpringPageableScanUtils.scanSortValidationEnums(openAPI, autoXSpringPaginated); - if (!sortValidationEnums.isEmpty()) { + if (SPRING_BOOT.equals(library)) { + pageableUtils.scanAll(openAPI, autoXSpringPaginated); + + if (generateSortValidation && useBeanValidation && !pageableUtils.sortValidationEnums.isEmpty()) { importMapping.putIfAbsent("ValidSort", configPackage + ".ValidSort"); supportingFiles.add(new SupportingFile("validSort.mustache", (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "ValidSort.java")); } - } - if (SPRING_BOOT.equals(library)) { - pageableDefaultsRegistry = SpringPageableScanUtils.scanPageableDefaults(openAPI, autoXSpringPaginated); - if (!pageableDefaultsRegistry.isEmpty()) { + if (!pageableUtils.pageableDefaultsRegistry.isEmpty()) { importMapping.putIfAbsent("PageableDefault", "org.springframework.data.web.PageableDefault"); importMapping.putIfAbsent("SortDefault", "org.springframework.data.web.SortDefault"); importMapping.putIfAbsent("Sort", "org.springframework.data.domain.Sort"); } - } - if (SPRING_BOOT.equals(library) && generatePageableConstraintValidation && useBeanValidation) { - pageableConstraintsRegistry = SpringPageableScanUtils.scanPageableConstraints(openAPI, autoXSpringPaginated); - if (!pageableConstraintsRegistry.isEmpty()) { + if (generatePageableConstraintValidation && useBeanValidation && !pageableUtils.pageableConstraintsRegistry.isEmpty()) { importMapping.putIfAbsent("ValidPageable", configPackage + ".ValidPageable"); supportingFiles.add(new SupportingFile("validPageable.mustache", (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "ValidPageable.java")); @@ -1216,27 +1208,19 @@ protected boolean isConstructorWithAllArgsAllowed(CodegenModel codegenModel) { @Override public CodegenOperation fromOperation(String path, String httpMethod, Operation operation, List servers) { - // Auto-detect pagination parameters and add x-spring-paginated if autoXSpringPaginated is enabled. + // Auto-detect pagination parameters and set x-spring-paginated if autoXSpringPaginated is enabled. + // Must be done BEFORE super.fromOperation() so that the base codegen populates + // codegenOperation.vendorExtensions from the extension we just set on 'operation'. // Only for spring-boot; respect manual x-spring-paginated: false override. - if (SPRING_BOOT.equals(library) && autoXSpringPaginated) { - if (operation.getExtensions() == null || !Boolean.FALSE.equals(operation.getExtensions().get("x-spring-paginated"))) { - if (operation.getParameters() != null) { - Set paramNames = operation.getParameters().stream() - .map(io.swagger.v3.oas.models.parameters.Parameter::getName) - .collect(Collectors.toSet()); - if (paramNames.containsAll(Arrays.asList("page", "size", "sort"))) { - if (operation.getExtensions() == null) { - operation.setExtensions(new HashMap<>()); - } - operation.getExtensions().put("x-spring-paginated", Boolean.TRUE); - } - } - } + if (SPRING_BOOT.equals(library)) { + SpringPageableScanUtils.applyAutoXSpringPaginatedIfNeeded(operation, autoXSpringPaginated); } - // add Pageable import only if x-spring-paginated explicitly used - // this allows to use a custom Pageable schema without importing Spring Pageable. - if (Boolean.TRUE.equals(operation.getExtensions().get("x-spring-paginated"))) { + // add Pageable import only if x-spring-paginated explicitly used AND it's a server library. + // this allows to use a custom Pageable schema without importing Spring Pageable, + // and avoids polluting the import mapping for client libraries. + if (SPRING_BOOT.equals(library) && operation.getExtensions() != null + && Boolean.TRUE.equals(operation.getExtensions().get("x-spring-paginated"))) { importMapping.put("Pageable", "org.springframework.data.domain.Pageable"); } @@ -1248,68 +1232,21 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation codegenOperation.allParams.stream().filter(p -> p.isDate || p.isDateTime).findFirst() .ifPresent(p -> codegenOperation.imports.add("DateTimeFormat")); // add org.springframework.data.domain.Pageable import when needed - if (codegenOperation.vendorExtensions.containsKey("x-spring-paginated")) { + // Only for spring-boot: client libraries (spring-cloud, spring-declarative-http-interface) + // need actual query parameters for HTTP calls, so x-spring-paginated is ignored for them. + if (SPRING_BOOT.equals(library) && codegenOperation.vendorExtensions.containsKey("x-spring-paginated")) { codegenOperation.imports.add("Pageable"); - if (DocumentationProvider.SPRINGDOC.equals(getDocumentationProvider())) { - codegenOperation.imports.add("ParameterObject"); - } - - // #8315 Spring Data Web default query params recognized by Pageable - List defaultPageableQueryParams = new ArrayList<>( - Arrays.asList("page", "size", "sort") - ); + SpringPageableScanUtils.applySpringDocPageableAnnotation(codegenOperation, + SpringPageableScanUtils.AnnotationSyntax.JAVA, + DocumentationProvider.SPRINGDOC.equals(getDocumentationProvider())); // #8315 Remove matching Spring Data Web default query params if 'x-spring-paginated' with Pageable is used - codegenOperation.queryParams.removeIf(param -> defaultPageableQueryParams.contains(param.baseName)); - codegenOperation.allParams.removeIf(param -> param.isQueryParam && defaultPageableQueryParams.contains(param.baseName)); - - // Build pageable parameter annotations (@ValidPageable, @ValidSort, @PageableDefault, @SortDefault.SortDefaults) - List pageableAnnotations = new ArrayList<>(); - - if (generatePageableConstraintValidation && useBeanValidation && pageableConstraintsRegistry.containsKey(codegenOperation.operationId)) { - SpringPageableScanUtils.PageableConstraintsData constraints = pageableConstraintsRegistry.get(codegenOperation.operationId); - List attrs = new ArrayList<>(); - if (constraints.maxSize >= 0) attrs.add("maxSize = " + constraints.maxSize); - if (constraints.maxPage >= 0) attrs.add("maxPage = " + constraints.maxPage); - if (constraints.minSize >= 0) attrs.add("minSize = " + constraints.minSize); - if (constraints.minPage >= 0) attrs.add("minPage = " + constraints.minPage); - pageableAnnotations.add("@ValidPageable(" + String.join(", ", attrs) + ")"); - codegenOperation.imports.add("ValidPageable"); - } - - if (generateSortValidation && useBeanValidation && sortValidationEnums.containsKey(codegenOperation.operationId)) { - List allowedSortValues = sortValidationEnums.get(codegenOperation.operationId); - // Java annotation arrays use {} syntax - String allowedValuesStr = allowedSortValues.stream() - .map(v -> "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\"") - .collect(Collectors.joining(", ")); - pageableAnnotations.add("@ValidSort(allowedValues = {" + allowedValuesStr + "})"); - codegenOperation.imports.add("ValidSort"); - } + SpringPageableScanUtils.removePageableQueryParams(codegenOperation); - if (pageableDefaultsRegistry.containsKey(codegenOperation.operationId)) { - SpringPageableScanUtils.PageableDefaultsData defaults = pageableDefaultsRegistry.get(codegenOperation.operationId); - if (defaults.page != null || defaults.size != null) { - List attrs = new ArrayList<>(); - if (defaults.page != null) attrs.add("page = " + defaults.page); - if (defaults.size != null) attrs.add("size = " + defaults.size); - pageableAnnotations.add("@PageableDefault(" + String.join(", ", attrs) + ")"); - codegenOperation.imports.add("PageableDefault"); - } - if (!defaults.sortDefaults.isEmpty()) { - // Java annotation arrays use @SortDefault(...) with {} for the sort field array - List sortEntries = defaults.sortDefaults.stream() - .map(sf -> "@SortDefault(sort = {\"" + sf.field + "\"}, direction = Sort.Direction." + sf.direction + ")") - .collect(Collectors.toList()); - pageableAnnotations.add("@SortDefault.SortDefaults({" + String.join(", ", sortEntries) + "})"); - codegenOperation.imports.add("SortDefault"); - codegenOperation.imports.add("Sort"); - } - } - - if (!pageableAnnotations.isEmpty()) { - codegenOperation.vendorExtensions.put("x-pageable-extra-annotation", pageableAnnotations); - } + // Build and attach pageable parameter annotations + pageableUtils.applyPageableAnnotations(codegenOperation, + generatePageableConstraintValidation, useBeanValidation, + generateSortValidation, SpringPageableScanUtils.AnnotationSyntax.JAVA); } if (codegenOperation.vendorExtensions.containsKey("x-spring-provide-args") && !provideArgsClassSet.isEmpty()) { codegenOperation.imports.addAll(provideArgsClassSet); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java index a8a8173e47c3..8dd5fe888a20 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java @@ -22,21 +22,57 @@ import io.swagger.v3.oas.models.PathItem; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.parameters.Parameter; +import org.openapitools.codegen.CodegenOperation; import org.openapitools.codegen.utils.ModelUtils; import java.util.*; import java.util.stream.Collectors; /** - * Language-agnostic utility methods for scanning OpenAPI specs for Spring Pageable-related - * features: sort enum validation, pageable defaults, and pageable constraints (max page/size). + * Utility class for scanning OpenAPI specs for Spring Pageable-related features: + * sort enum validation, pageable defaults, and pageable constraints (max page/size). + * + *

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

* *

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

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

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

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

+ */ + public static final List DEFAULT_PAGEABLE_QUERY_PARAMS = + Collections.unmodifiableList(Arrays.asList(PAGE, SIZE, SORT)); + + // ------------------------------------------------------------------------- + // Instance state (populated by scanAll) + // ------------------------------------------------------------------------- + + /** Map from operationId to allowed sort values; populated by {@link #scanAll}. */ + public Map> sortValidationEnums = new HashMap<>(); + + /** Map from operationId to pageable defaults; populated by {@link #scanAll}. */ + public Map pageableDefaultsRegistry = new HashMap<>(); + + /** Map from operationId to pageable constraints; populated by {@link #scanAll}. */ + public Map pageableConstraintsRegistry = new HashMap<>(); - private SpringPageableScanUtils() {} + public SpringPageableScanUtils() {} // ------------------------------------------------------------------------- // Data classes @@ -96,6 +132,43 @@ public boolean hasAny() { } } + // ------------------------------------------------------------------------- + // Instance methods + // ------------------------------------------------------------------------- + + /** + * Populates {@link #sortValidationEnums}, {@link #pageableDefaultsRegistry}, and + * {@link #pageableConstraintsRegistry} by scanning the given OpenAPI document. + * + *

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

+ * + * @param openAPI the OpenAPI document to scan + * @param autoXSpringPaginated whether auto-detection of pageable operations is enabled + */ + public void scanAll(OpenAPI openAPI, boolean autoXSpringPaginated) { + sortValidationEnums = scanSortValidationEnums(openAPI, autoXSpringPaginated); + pageableDefaultsRegistry = scanPageableDefaults(openAPI, autoXSpringPaginated); + pageableConstraintsRegistry = scanPageableConstraints(openAPI, autoXSpringPaginated); + } + + /** + * Instance variant of {@link #applyPageableAnnotations(CodegenOperation, boolean, boolean, + * Map, boolean, Map, Map, AnnotationSyntax)} that uses the maps populated by + * {@link #scanAll(OpenAPI, boolean)}. + */ + public void applyPageableAnnotations( + CodegenOperation codegenOperation, + boolean generatePageableConstraintValidation, + boolean useBeanValidation, + boolean generateSortValidation, + AnnotationSyntax syntax) { + applyPageableAnnotations(codegenOperation, + generatePageableConstraintValidation, useBeanValidation, pageableConstraintsRegistry, + generateSortValidation, sortValidationEnums, + pageableDefaultsRegistry, syntax); + } + // ------------------------------------------------------------------------- // Scan methods // ------------------------------------------------------------------------- @@ -120,11 +193,266 @@ public static boolean willBePageable(Operation operation, boolean autoXSpringPag Set paramNames = operation.getParameters().stream() .map(Parameter::getName) .collect(Collectors.toSet()); - return paramNames.containsAll(Arrays.asList("page", "size", "sort")); + return paramNames.containsAll(DEFAULT_PAGEABLE_QUERY_PARAMS); + } + return false; + } + + /** + * Auto-detects Pageable pagination query parameters and, when detected, mutates the + * operation by setting {@code x-spring-paginated: true} on its vendor extensions. + * + *

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

+ * + *

Rules (in priority order):

+ *
    + *
  1. If {@code x-spring-paginated} is explicitly {@code false} → do nothing, return {@code false}.
  2. + *
  3. If {@code x-spring-paginated} is already {@code true} → return {@code true} without re-mutating.
  4. + *
  5. If {@code autoXSpringPaginated} is {@code true} and the operation has all three + * {@link #DEFAULT_PAGEABLE_QUERY_PARAMS} ({@code page}, {@code size}, {@code sort}) + * → set {@code x-spring-paginated: true} and return {@code true}.
  6. + *
  7. Otherwise → return {@code false}.
  8. + *
+ * + * @param operation the raw OpenAPI {@link Operation} to inspect (and possibly mutate) + * @param autoXSpringPaginated whether auto-detection is enabled for this generator + * @return {@code true} if the operation is (or was just marked as) paginated + */ + public static boolean applyAutoXSpringPaginatedIfNeeded( + Operation operation, boolean autoXSpringPaginated) { + if (operation.getExtensions() != null) { + Object paginated = operation.getExtensions().get("x-spring-paginated"); + if (Boolean.FALSE.equals(paginated)) { + return false; + } + if (Boolean.TRUE.equals(paginated)) { + return true; + } + } + if (autoXSpringPaginated && operation.getParameters() != null) { + Set paramNames = operation.getParameters().stream() + .map(Parameter::getName) + .collect(Collectors.toSet()); + if (paramNames.containsAll(DEFAULT_PAGEABLE_QUERY_PARAMS)) { + if (operation.getExtensions() == null) { + operation.setExtensions(new HashMap<>()); + } + operation.getExtensions().put("x-spring-paginated", Boolean.TRUE); + return true; + } } return false; } + /** + * Removes the three Spring Data Web default pagination query parameters ({@code page}, + * {@code size}, {@code sort}) from the given codegen operation's parameter lists. + * + *

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

+ * + *

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

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

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

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

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

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

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

+ * + *

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

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

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

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

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

+ * + * @param codegenOperation the operation to annotate + * @param syntax target language annotation syntax + * @param isSpringDoc whether the active documentation provider is SpringDoc + */ + public static void applySpringDocPageableAnnotation( + CodegenOperation codegenOperation, AnnotationSyntax syntax, boolean isSpringDoc) { + if (!isSpringDoc) { + return; + } + if (syntax == AnnotationSyntax.JAVA) { + codegenOperation.imports.add("ParameterObject"); + } else { + codegenOperation.imports.add("PageableAsQueryParam"); + Object existingAnnotation = codegenOperation.vendorExtensions.get("x-operation-extra-annotation"); + List existing = getObjectAsStringList(existingAnnotation); + List updated = new ArrayList<>(); + updated.add("@PageableAsQueryParam"); + updated.addAll(existing); + codegenOperation.vendorExtensions.put("x-operation-extra-annotation", updated); + } + } + + @SuppressWarnings("unchecked") + private static List getObjectAsStringList(Object object) { + if (object instanceof List) { + return (List) object; + } else if (object instanceof String) { + return Collections.singletonList((String) object); + } + return new ArrayList<>(); + } + + // ------------------------------------------------------------------------- + // Scan methods + // ------------------------------------------------------------------------- + /** * Scans all pageable operations for a {@code sort} parameter with enum values. * @@ -139,14 +467,13 @@ public static Map> scanSortValidationEnums( for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { for (Operation operation : pathEntry.getValue().readOperations()) { String operationId = operation.getOperationId(); - if (operationId == null || !willBePageable(operation, autoXSpringPaginated)) { - continue; - } - if (operation.getParameters() == null) { + if (operationId == null + || operation.getParameters() == null + || !willBePageable(operation, autoXSpringPaginated)) { continue; } for (Parameter param : operation.getParameters()) { - if (!"sort".equals(param.getName())) { + if (!SORT.equals(param.getName())) { continue; } Schema schema = param.getSchema(); @@ -196,10 +523,9 @@ public static Map scanPageableDefaults( for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { for (Operation operation : pathEntry.getValue().readOperations()) { String operationId = operation.getOperationId(); - if (operationId == null || !willBePageable(operation, autoXSpringPaginated)) { - continue; - } - if (operation.getParameters() == null) { + if (operationId == null + || !willBePageable(operation, autoXSpringPaginated) + || operation.getParameters() == null) { continue; } Integer pageDefault = null; @@ -216,17 +542,17 @@ public static Map scanPageableDefaults( continue; } switch (param.getName()) { - case "page": + case PAGE: if (defaultValue instanceof Number) { pageDefault = ((Number) defaultValue).intValue(); } break; - case "size": + case SIZE: if (defaultValue instanceof Number) { sizeDefault = ((Number) defaultValue).intValue(); } break; - case "sort": + case SORT: List sortValues = new ArrayList<>(); if (defaultValue instanceof String) { sortValues.add((String) defaultValue); @@ -275,10 +601,9 @@ public static Map scanPageableConstraints( for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { for (Operation operation : pathEntry.getValue().readOperations()) { String operationId = operation.getOperationId(); - if (operationId == null || !willBePageable(operation, autoXSpringPaginated)) { - continue; - } - if (operation.getParameters() == null) { + if (operationId == null + || !willBePageable(operation, autoXSpringPaginated) + || operation.getParameters() == null) { continue; } int maxPage = -1; @@ -292,20 +617,22 @@ public static Map scanPageableConstraints( } ModelUtils.ResolvedMaxBound maxBound = ModelUtils.resolveMaximumBound(openAPI, schema); ModelUtils.ResolvedMinBound minBound = ModelUtils.resolveMinimumBound(openAPI, schema); - Integer adjustedMaxBound = maxBound == null - ? null - : (maxBound.exclusive ? maxBound.maxBound.intValue() - 1 : maxBound.maxBound.intValue()); - Integer adjustedMinBound = minBound == null - ? null - : (minBound.exclusive ? minBound.minBound.intValue() + 1 : minBound.minBound.intValue()); switch (param.getName()) { - case "page": - if (adjustedMaxBound != null) maxPage = adjustedMaxBound; - if (adjustedMinBound != null) minPage = adjustedMinBound; + case PAGE: + if (maxBound != null) { + maxPage = toIntInclusiveMax(maxBound); + } + if (minBound != null) { + minPage = toIntInclusiveMin(minBound); + } break; - case "size": - if (adjustedMaxBound != null) maxSize = adjustedMaxBound; - if (adjustedMinBound != null) minSize = adjustedMinBound; + case SIZE: + if (maxBound != null) { + maxSize = toIntInclusiveMax(maxBound); + } + if (minBound != null) { + minSize = toIntInclusiveMin(minBound); + } break; default: break; @@ -320,4 +647,19 @@ public static Map scanPageableConstraints( return result; } + private static Integer toIntInclusiveMax(ModelUtils.ResolvedMaxBound maxBound) { + if (maxBound == null) { + return null; + } + return maxBound.exclusive ? maxBound.maxBound.intValue() - 1 : maxBound.maxBound.intValue(); + } + + private static Integer toIntInclusiveMin(ModelUtils.ResolvedMinBound minBound) { + if (minBound == null) { + return null; + } + return minBound.exclusive ? minBound.minBound.intValue() + 1 : minBound.minBound.intValue(); + } + + } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index 78fed854c3b3..4e261ae471da 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -1050,16 +1050,22 @@ public static Object resolveDefault(OpenAPI openAPI, Schema schema) { // inline default value takes precedence return defaultValue; } - return !hasAllOf(schema) - ? null - : schema.getAllOf().stream() - // recursive search for default - .map(item -> resolveDefault(openAPI, item)) - .filter(Objects::nonNull) - // first non-null default in allOf wins. - // This is very arbitrary and might not be correct behavior, since behavior regarding default inheritance/overriding is unspecified - .findFirst() - .orElse(null); + if (hasAllOf(schema)) { + return getFirstNonNullDefault(openAPI, schema).orElse(null); + } + return null; + } + + /** + * Recursively searches {@code allOf} items for the first non-null {@code default} value. + * The first non-null default in {@code allOf} wins. + * This behavior is arbitrary and may not reflect intentions of the spec creator, as default's inheritance/overriding behavior is undefined. + */ + private static @NonNull Optional getFirstNonNullDefault(OpenAPI openAPI, Schema schema) { + return schema.getAllOf().stream() + .map(item -> resolveDefault(openAPI, item)) + .filter(Objects::nonNull) + .findFirst(); } public static boolean hasValidation(Schema sc) { diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java index a2e7136a8259..e42586035c83 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/languages/SpringPageableScanUtilsTest.java @@ -9,9 +9,13 @@ import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.media.StringSchema; import io.swagger.v3.oas.models.parameters.Parameter; +import org.openapitools.codegen.CodegenOperation; import org.testng.annotations.Test; import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -275,4 +279,324 @@ public void scanSortValidationEnums_sortSchemaWithNoEnum_returnsEmptyMap() { assertThat(SpringPageableScanUtils.scanSortValidationEnums(openAPI, false)).isEmpty(); } + + // ------------------------------------------------------------------------- + // applyAutoXSpringPaginatedIfNeeded + // ------------------------------------------------------------------------- + + @Test + public void applyAutoXSpringPaginatedIfNeeded_allThreeParams_setsExtensionAndReturnsTrue() { + Operation op = new Operation(); + op.addParametersItem(new Parameter().name("page")); + op.addParametersItem(new Parameter().name("size")); + op.addParametersItem(new Parameter().name("sort")); + + boolean result = SpringPageableScanUtils.applyAutoXSpringPaginatedIfNeeded(op, true); + + assertThat(result).isTrue(); + assertThat(op.getExtensions()).containsEntry("x-spring-paginated", Boolean.TRUE); + } + + @Test + public void applyAutoXSpringPaginatedIfNeeded_missingOneParam_doesNotSetExtension() { + Operation op = new Operation(); + op.addParametersItem(new Parameter().name("page")); + op.addParametersItem(new Parameter().name("size")); + // 'sort' is absent + + boolean result = SpringPageableScanUtils.applyAutoXSpringPaginatedIfNeeded(op, true); + + assertThat(result).isFalse(); + assertThat(op.getExtensions()).isNull(); + } + + @Test + public void applyAutoXSpringPaginatedIfNeeded_autoDisabled_doesNotSetExtension() { + Operation op = new Operation(); + op.addParametersItem(new Parameter().name("page")); + op.addParametersItem(new Parameter().name("size")); + op.addParametersItem(new Parameter().name("sort")); + + boolean result = SpringPageableScanUtils.applyAutoXSpringPaginatedIfNeeded(op, false); + + assertThat(result).isFalse(); + assertThat(op.getExtensions()).isNull(); + } + + @Test + public void applyAutoXSpringPaginatedIfNeeded_explicitlyTrue_returnsTrueWithoutMutation() { + Operation op = new Operation(); + op.addExtension("x-spring-paginated", Boolean.TRUE); + // No params needed — already explicitly set + + boolean result = SpringPageableScanUtils.applyAutoXSpringPaginatedIfNeeded(op, false); + + assertThat(result).isTrue(); + // Extension was already true and must remain true + assertThat(op.getExtensions()).containsEntry("x-spring-paginated", Boolean.TRUE); + } + + @Test + public void applyAutoXSpringPaginatedIfNeeded_explicitlyFalse_returnsFalseAndIsNotOverridden() { + Operation op = new Operation(); + op.addExtension("x-spring-paginated", Boolean.FALSE); + op.addParametersItem(new Parameter().name("page")); + op.addParametersItem(new Parameter().name("size")); + op.addParametersItem(new Parameter().name("sort")); + + boolean result = SpringPageableScanUtils.applyAutoXSpringPaginatedIfNeeded(op, true); + + assertThat(result).isFalse(); + // Manual false must not be overridden by auto-detection + assertThat(op.getExtensions()).containsEntry("x-spring-paginated", Boolean.FALSE); + } + + @Test + public void applyAutoXSpringPaginatedIfNeeded_noParams_doesNotSetExtension() { + Operation op = new Operation(); + // No parameters at all + + boolean result = SpringPageableScanUtils.applyAutoXSpringPaginatedIfNeeded(op, true); + + assertThat(result).isFalse(); + assertThat(op.getExtensions()).isNull(); + } + + // ------------------------------------------------------------------------- + // applyPageableAnnotations + // ------------------------------------------------------------------------- + + private static CodegenOperation minimalOp(String operationId) { + CodegenOperation op = new CodegenOperation(); + op.operationId = operationId; + return op; + } + + @Test + public void applyPageableAnnotations_validPageable_java_formatsWithAttrs() { + CodegenOperation op = minimalOp("listItems"); + SpringPageableScanUtils.PageableConstraintsData constraints = + new SpringPageableScanUtils.PageableConstraintsData(100, 50, 0, 1); + Map registry = + Collections.singletonMap("listItems", constraints); + + SpringPageableScanUtils.applyPageableAnnotations(op, true, true, registry, + false, Collections.emptyMap(), Collections.emptyMap(), + SpringPageableScanUtils.AnnotationSyntax.JAVA); + + assertThat(op.vendorExtensions).containsKey("x-pageable-extra-annotation"); + List annotations = (List) op.vendorExtensions.get("x-pageable-extra-annotation"); + assertThat(annotations).hasSize(1); + assertThat(annotations.get(0)) + .startsWith("@ValidPageable(") + .contains("maxPage = 100") + .contains("maxSize = 50") + .contains("minPage = 0") + .contains("minSize = 1"); + assertThat(op.imports).contains("ValidPageable"); + } + + @Test + public void applyPageableAnnotations_validSort_javaSyntax_usesCurlyBraces() { + CodegenOperation op = minimalOp("listItems"); + Map> sortEnums = Collections.singletonMap("listItems", + List.of("name,asc", "name,desc")); + + SpringPageableScanUtils.applyPageableAnnotations(op, false, true, Collections.emptyMap(), + true, sortEnums, Collections.emptyMap(), + SpringPageableScanUtils.AnnotationSyntax.JAVA); + + List annotations = (List) op.vendorExtensions.get("x-pageable-extra-annotation"); + assertThat(annotations).hasSize(1); + assertThat(annotations.get(0)) + .isEqualTo("@ValidSort(allowedValues = {\"name,asc\", \"name,desc\"})"); + assertThat(op.imports).contains("ValidSort"); + } + + @Test + public void applyPageableAnnotations_validSort_kotlinSyntax_usesSquareBrackets() { + CodegenOperation op = minimalOp("listItems"); + Map> sortEnums = Collections.singletonMap("listItems", + List.of("name,asc", "name,desc")); + + SpringPageableScanUtils.applyPageableAnnotations(op, false, true, Collections.emptyMap(), + true, sortEnums, Collections.emptyMap(), + SpringPageableScanUtils.AnnotationSyntax.KOTLIN); + + List annotations = (List) op.vendorExtensions.get("x-pageable-extra-annotation"); + assertThat(annotations).hasSize(1); + assertThat(annotations.get(0)) + .isEqualTo("@ValidSort(allowedValues = [\"name,asc\", \"name,desc\"])"); + } + + @Test + public void applyPageableAnnotations_pageableDefault_pageAndSize() { + CodegenOperation op = minimalOp("listItems"); + SpringPageableScanUtils.PageableDefaultsData defaults = + new SpringPageableScanUtils.PageableDefaultsData(0, 20, Collections.emptyList()); + Map registry = + Collections.singletonMap("listItems", defaults); + + SpringPageableScanUtils.applyPageableAnnotations(op, false, false, Collections.emptyMap(), + false, Collections.emptyMap(), registry, + SpringPageableScanUtils.AnnotationSyntax.JAVA); + + List annotations = (List) op.vendorExtensions.get("x-pageable-extra-annotation"); + assertThat(annotations).hasSize(1); + assertThat(annotations.get(0)).isEqualTo("@PageableDefault(page = 0, size = 20)"); + assertThat(op.imports).contains("PageableDefault"); + } + + @Test + public void applyPageableAnnotations_sortDefault_javaSyntax() { + CodegenOperation op = minimalOp("listItems"); + List sortFields = List.of( + new SpringPageableScanUtils.SortFieldDefault("name", "ASC"), + new SpringPageableScanUtils.SortFieldDefault("id", "DESC") + ); + SpringPageableScanUtils.PageableDefaultsData defaults = + new SpringPageableScanUtils.PageableDefaultsData(null, null, sortFields); + Map registry = + Collections.singletonMap("listItems", defaults); + + SpringPageableScanUtils.applyPageableAnnotations(op, false, false, Collections.emptyMap(), + false, Collections.emptyMap(), registry, + SpringPageableScanUtils.AnnotationSyntax.JAVA); + + List annotations = (List) op.vendorExtensions.get("x-pageable-extra-annotation"); + assertThat(annotations).hasSize(1); + assertThat(annotations.get(0)) + .isEqualTo("@SortDefault.SortDefaults({" + + "@SortDefault(sort = {\"name\"}, direction = Sort.Direction.ASC), " + + "@SortDefault(sort = {\"id\"}, direction = Sort.Direction.DESC)})"); + assertThat(op.imports).containsAll(List.of("SortDefault", "Sort")); + } + + @Test + public void applyPageableAnnotations_sortDefault_kotlinSyntax() { + CodegenOperation op = minimalOp("listItems"); + List sortFields = List.of( + new SpringPageableScanUtils.SortFieldDefault("name", "ASC") + ); + SpringPageableScanUtils.PageableDefaultsData defaults = + new SpringPageableScanUtils.PageableDefaultsData(null, null, sortFields); + Map registry = + Collections.singletonMap("listItems", defaults); + + SpringPageableScanUtils.applyPageableAnnotations(op, false, false, Collections.emptyMap(), + false, Collections.emptyMap(), registry, + SpringPageableScanUtils.AnnotationSyntax.KOTLIN); + + List annotations = (List) op.vendorExtensions.get("x-pageable-extra-annotation"); + assertThat(annotations).hasSize(1); + assertThat(annotations.get(0)) + .isEqualTo("@SortDefault.SortDefaults(SortDefault(sort = [\"name\"], direction = Sort.Direction.ASC))"); + } + + @Test + public void applyPageableAnnotations_noMatchingRegistryEntries_noAnnotationsAdded() { + CodegenOperation op = minimalOp("someOtherOp"); + + SpringPageableScanUtils.applyPageableAnnotations(op, true, true, + Collections.singletonMap("differentOp", new SpringPageableScanUtils.PageableConstraintsData(10, 5, 0, 1)), + true, + Collections.singletonMap("differentOp", List.of("id,asc")), + Collections.singletonMap("differentOp", new SpringPageableScanUtils.PageableDefaultsData(0, 10, Collections.emptyList())), + SpringPageableScanUtils.AnnotationSyntax.JAVA); + + assertThat(op.vendorExtensions).doesNotContainKey("x-pageable-extra-annotation"); + assertThat(op.imports).isEmpty(); + } + + // ------------------------------------------------------------------------- + // applySpringDocPageableAnnotation + // ------------------------------------------------------------------------- + + @Test + public void applySpringDocPageableAnnotation_javaSyntax_springDoc_addsParameterObjectImport() { + CodegenOperation op = minimalOp("listItems"); + + SpringPageableScanUtils.applySpringDocPageableAnnotation( + op, SpringPageableScanUtils.AnnotationSyntax.JAVA, true); + + assertThat(op.imports).contains("ParameterObject"); + assertThat(op.imports).doesNotContain("PageableAsQueryParam"); + assertThat(op.vendorExtensions).doesNotContainKey("x-operation-extra-annotation"); + } + + @Test + public void applySpringDocPageableAnnotation_kotlinSyntax_springDoc_addsImportAndPrependsAnnotation() { + CodegenOperation op = minimalOp("listItems"); + + SpringPageableScanUtils.applySpringDocPageableAnnotation( + op, SpringPageableScanUtils.AnnotationSyntax.KOTLIN, true); + + assertThat(op.imports).contains("PageableAsQueryParam"); + assertThat(op.imports).doesNotContain("ParameterObject"); + List extraAnnotations = (List) op.vendorExtensions.get("x-operation-extra-annotation"); + assertThat(extraAnnotations).containsExactly("@PageableAsQueryParam"); + } + + @Test + public void applySpringDocPageableAnnotation_kotlinSyntax_springDoc_prependsToExistingAnnotations() { + CodegenOperation op = minimalOp("listItems"); + List existing = new ArrayList<>(); + existing.add("@PreAuthorize(\"hasRole('ADMIN')\")"); + op.vendorExtensions.put("x-operation-extra-annotation", existing); + + SpringPageableScanUtils.applySpringDocPageableAnnotation( + op, SpringPageableScanUtils.AnnotationSyntax.KOTLIN, true); + + List extraAnnotations = (List) op.vendorExtensions.get("x-operation-extra-annotation"); + assertThat(extraAnnotations).containsExactly("@PageableAsQueryParam", "@PreAuthorize(\"hasRole('ADMIN')\")"); + } + + @Test + public void applySpringDocPageableAnnotation_notSpringDoc_isNoOp() { + CodegenOperation op = minimalOp("listItems"); + + SpringPageableScanUtils.applySpringDocPageableAnnotation( + op, SpringPageableScanUtils.AnnotationSyntax.KOTLIN, false); + + assertThat(op.imports).isEmpty(); + assertThat(op.vendorExtensions).doesNotContainKey("x-operation-extra-annotation"); + } + + // ------------------------------------------------------------------------- + // Instance: scanAll + applyPageableAnnotations + // ------------------------------------------------------------------------- + + @Test + public void scanAll_populatesInstanceMaps() { + Parameter pageParam = new Parameter().name("page").schema(new IntegerSchema()); + Parameter sizeParam = new Parameter().name("size").schema(new IntegerSchema()); + Parameter sortParam = new Parameter().name("sort").schema( + new StringSchema().addEnumItem("name,asc").addEnumItem("name,desc")); + OpenAPI openAPI = buildPageableOperationWithParams(List.of(pageParam, sizeParam, sortParam)); + + SpringPageableScanUtils utils = new SpringPageableScanUtils(); + utils.scanAll(openAPI, false); // auto-detect disabled; x-spring-paginated already set + + assertThat(utils.sortValidationEnums).containsKey("listItems"); + assertThat(utils.sortValidationEnums.get("listItems")).containsExactly("name,asc", "name,desc"); + // No page/size defaults or constraints in this spec + assertThat(utils.pageableDefaultsRegistry).doesNotContainKey("listItems"); + assertThat(utils.pageableConstraintsRegistry).doesNotContainKey("listItems"); + } + + @Test + public void instanceApplyPageableAnnotations_usesStoredMaps() { + CodegenOperation op = minimalOp("listItems"); + Map> sortEnums = Collections.singletonMap("listItems", List.of("id,asc")); + + SpringPageableScanUtils utils = new SpringPageableScanUtils(); + utils.sortValidationEnums = sortEnums; + + utils.applyPageableAnnotations(op, false, true, true, SpringPageableScanUtils.AnnotationSyntax.JAVA); + + List annotations = (List) op.vendorExtensions.get("x-pageable-extra-annotation"); + assertThat(annotations).hasSize(1); + assertThat(annotations.get(0)).isEqualTo("@ValidSort(allowedValues = {\"id,asc\"})"); + assertThat(op.imports).contains("ValidSort"); + } } diff --git a/samples/client/petstore/spring-cloud-tags/src/main/java/org/openapitools/api/PetController.java b/samples/client/petstore/spring-cloud-tags/src/main/java/org/openapitools/api/PetController.java index 01a1ef7cd935..1e637f43a1a8 100644 --- a/samples/client/petstore/spring-cloud-tags/src/main/java/org/openapitools/api/PetController.java +++ b/samples/client/petstore/spring-cloud-tags/src/main/java/org/openapitools/api/PetController.java @@ -7,8 +7,6 @@ import org.openapitools.model.ModelApiResponse; import org.springframework.lang.Nullable; -import org.springframework.data.domain.Pageable; -import org.springdoc.core.annotations.ParameterObject; import org.openapitools.model.Pet; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; diff --git a/samples/openapi3/client/petstore/spring-cloud-spring-pageable/src/main/java/org/openapitools/api/PetApi.java b/samples/openapi3/client/petstore/spring-cloud-spring-pageable/src/main/java/org/openapitools/api/PetApi.java index 320effcb2eae..4949b2dae998 100644 --- a/samples/openapi3/client/petstore/spring-cloud-spring-pageable/src/main/java/org/openapitools/api/PetApi.java +++ b/samples/openapi3/client/petstore/spring-cloud-spring-pageable/src/main/java/org/openapitools/api/PetApi.java @@ -7,8 +7,6 @@ import org.openapitools.model.ModelApiResponse; import org.springframework.lang.Nullable; -import org.springframework.data.domain.Pageable; -import org.springdoc.core.annotations.ParameterObject; import org.openapitools.model.Pet; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; @@ -140,6 +138,9 @@ ResponseEntity> findPetsByStatus( * Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. * * @param tags Tags to filter by (required) + * @param size2 The number of items to return per page. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used. (required) + * @param page The page to return, starting with page 0. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used. (required) + * @param sort The sorting to apply to the Pageable object. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used. (required) * @param size A test HeaderParam for issue #8315 - must NOT be removed when x-spring-paginated:true is used. (optional) * @return successful operation (status code 200) * or Invalid tag value (status code 400) @@ -170,6 +171,9 @@ ResponseEntity> findPetsByStatus( ) ResponseEntity> findPetsByTags( @NotNull @Parameter(name = "tags", description = "Tags to filter by", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "tags", required = true) List tags, + @NotNull @Min(value = 1) @Parameter(name = "size", description = "The number of items to return per page. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "size", required = true, defaultValue = "20") Integer size2, + @NotNull @Min(value = 0) @Parameter(name = "page", description = "The page to return, starting with page 0. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "page", required = true, defaultValue = "0") Integer page, + @NotNull @Parameter(name = "sort", description = "The sorting to apply to the Pageable object. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "sort", required = true, defaultValue = "id,asc") String sort, @Parameter(name = "size", description = "A test HeaderParam for issue #8315 - must NOT be removed when x-spring-paginated:true is used.", in = ParameterIn.HEADER) @RequestHeader(value = "size", required = false) @Nullable String size, @ParameterObject final Pageable pageable ); @@ -217,6 +221,9 @@ ResponseEntity getPetById( * GET /pet/all : List all pets * Returns all pets with pagination support * + * @param page The page number to return. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used. (optional, default to 0) + * @param size The number of items to return per page. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used. (optional, default to 20) + * @param sort The sort order. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used. (optional) * @return successful operation (status code 200) * or Invalid status value (status code 400) */ @@ -243,6 +250,9 @@ ResponseEntity getPetById( ) @org.springframework.validation.annotation.Validated ResponseEntity> listAllPets( + @Parameter(name = "page", description = "The page number to return. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", in = ParameterIn.QUERY) @Valid @RequestParam(value = "page", required = false, defaultValue = "0") Integer page, + @Parameter(name = "size", description = "The number of items to return per page. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", in = ParameterIn.QUERY) @Valid @RequestParam(value = "size", required = false, defaultValue = "20") Integer size, + @Parameter(name = "sort", description = "The sort order. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", in = ParameterIn.QUERY) @Valid @RequestParam(value = "sort", required = false) @Nullable String sort, @ParameterObject final Pageable pageable ); From 5eba33355e9e33266bc69f593e0401060ea22e25 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 15 May 2026 01:47:39 +0200 Subject: [PATCH 17/21] feat: add support for ValuedEnum interface in code generation for enums --- docs/generators/java-camel.md | 1 + docs/generators/kotlin-spring.md | 1 + docs/generators/spring.md | 1 + .../codegen/CodegenConstants.java | 7 + .../languages/EnumValueInterfaceUtils.java | 134 ++++++++++++++++++ .../languages/KotlinSpringServerCodegen.java | 23 ++- .../codegen/languages/SpringCodegen.java | 21 ++- .../JavaSpring/enumValueInterface.mustache | 6 + .../kotlin-spring/dataClass.mustache | 2 +- .../kotlin-spring/enumClass.mustache | 2 +- .../kotlin-spring/enumValueInterface.mustache | 6 + .../java/spring/SpringCodegenTest.java | 75 +++++++++- .../spring/KotlinSpringServerCodegenTest.java | 73 ++++++++++ .../3_0/spring/enum-value-interface.yaml | 42 ++++++ 14 files changed, 385 insertions(+), 9 deletions(-) create mode 100644 modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/EnumValueInterfaceUtils.java create mode 100644 modules/openapi-generator/src/main/resources/JavaSpring/enumValueInterface.mustache create mode 100644 modules/openapi-generator/src/main/resources/kotlin-spring/enumValueInterface.mustache create mode 100644 modules/openapi-generator/src/test/resources/3_0/spring/enum-value-interface.yaml diff --git a/docs/generators/java-camel.md b/docs/generators/java-camel.md index 2ac49f9d6eaf..9b007dbf9103 100644 --- a/docs/generators/java-camel.md +++ b/docs/generators/java-camel.md @@ -111,6 +111,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |useBeanValidation|Use BeanValidation API annotations| |true| |useDeductionForOneOfInterfaces|Annotate discriminator-free oneOf interfaces with Jackson's @JsonTypeInfo(use = Id.DEDUCTION) and @JsonSubTypes so the concrete subtype is resolved from the JSON field set rather than a type-tag property. Has no effect when a discriminator is present (name-based resolution is used instead). Requires subtypes to have structurally distinct sets of properties.| |false| |useEnumCaseInsensitive|Use `equalsIgnoreCase` when String for enum comparison| |false| +|useEnumValueInterface|Generate a ValuedEnum<T> interface in the config package and make all generated enums implement it, providing a common typed way to access the underlying enum value. Use `importMappings.ValuedEnum` to substitute a custom/library-provided interface instead of generating one.| |false| |useFeignClientContextId|Whether to generate Feign client with contextId parameter.| |true| |useFeignClientUrl|Whether to generate Feign client with url parameter.| |true| |useHttpServiceProxyFactoryInterfacesConfigurator|Generate HttpInterfacesAbstractConfigurator based on an HttpServiceProxyFactory instance (as opposed to a WebClient instance, when disabled) for generating Spring HTTP interfaces.| |false| diff --git a/docs/generators/kotlin-spring.md b/docs/generators/kotlin-spring.md index 584c4e1c9cb3..3d716858bc5e 100644 --- a/docs/generators/kotlin-spring.md +++ b/docs/generators/kotlin-spring.md @@ -62,6 +62,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |title|server title name or client service name| |OpenAPI Kotlin Spring| |useBeanValidation|Use BeanValidation API annotations to validate data types| |true| |useDeductionForOneOfInterfaces|Annotate discriminator-free oneOf interfaces with Jackson's @JsonTypeInfo(use = Id.DEDUCTION) and @JsonSubTypes so the concrete subtype is resolved from the JSON field set rather than a type-tag property. Has no effect when a discriminator is present (name-based resolution is used instead). Requires subtypes to have structurally distinct sets of properties.| |false| +|useEnumValueInterface|Generate a ValuedEnum<T> interface in the config package and make all generated enums implement it, providing a common typed way to access the underlying enum value. Use `importMappings.ValuedEnum` to substitute a custom/library-provided interface instead of generating one.| |false| |useFeignClientUrl|Whether to generate Feign client with url parameter.| |true| |useFlowForArrayReturnType|Whether to use Flow for array/collection return types when reactive is enabled. If false, will use List instead.| |true| |useJackson3|Use Jackson 3 dependencies (tools.jackson package). Only available with `useSpringBoot4`. Defaults to true when `useSpringBoot4` is enabled. Incompatible with `openApiNullable`.| |false| diff --git a/docs/generators/spring.md b/docs/generators/spring.md index 0542bced8084..a247d7accebd 100644 --- a/docs/generators/spring.md +++ b/docs/generators/spring.md @@ -104,6 +104,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |useBeanValidation|Use BeanValidation API annotations| |true| |useDeductionForOneOfInterfaces|Annotate discriminator-free oneOf interfaces with Jackson's @JsonTypeInfo(use = Id.DEDUCTION) and @JsonSubTypes so the concrete subtype is resolved from the JSON field set rather than a type-tag property. Has no effect when a discriminator is present (name-based resolution is used instead). Requires subtypes to have structurally distinct sets of properties.| |false| |useEnumCaseInsensitive|Use `equalsIgnoreCase` when String for enum comparison| |false| +|useEnumValueInterface|Generate a ValuedEnum<T> interface in the config package and make all generated enums implement it, providing a common typed way to access the underlying enum value. Use `importMappings.ValuedEnum` to substitute a custom/library-provided interface instead of generating one.| |false| |useFeignClientContextId|Whether to generate Feign client with contextId parameter.| |true| |useFeignClientUrl|Whether to generate Feign client with url parameter.| |true| |useHttpServiceProxyFactoryInterfacesConfigurator|Generate HttpInterfacesAbstractConfigurator based on an HttpServiceProxyFactory instance (as opposed to a WebClient instance, when disabled) for generating Spring HTTP interfaces.| |false| diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java index 7dd1947471c1..46cc8010ef71 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenConstants.java @@ -489,6 +489,13 @@ public static enum ENUM_PROPERTY_NAMING_TYPE {camelCase, PascalCase, snake_case, public static final String X_MODEL_IS_MUTABLE = "x-model-is-mutable"; public static final String X_IMPLEMENTS = "x-implements"; public static final String X_IS_ONE_OF_INTERFACE = "x-is-one-of-interface"; + public static final String USE_ENUM_VALUE_INTERFACE = "useEnumValueInterface"; + public static final String USE_ENUM_VALUE_INTERFACE_DESC = + "Generate a ValuedEnum interface in the config package and make all generated enums " + + "implement it, providing a common typed way to access the underlying enum value. " + + "Use `importMappings.ValuedEnum` to substitute a custom/library-provided interface " + + "instead of generating one."; + public static final String USE_DEDUCTION_FOR_ONE_OF_INTERFACES = "useDeductionForOneOfInterfaces"; public static final String USE_DEDUCTION_FOR_ONE_OF_INTERFACES_DESC = "Annotate discriminator-free oneOf interfaces with Jackson's " + diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/EnumValueInterfaceUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/EnumValueInterfaceUtils.java new file mode 100644 index 000000000000..60f9ff8e9a4d --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/EnumValueInterfaceUtils.java @@ -0,0 +1,134 @@ +package org.openapitools.codegen.languages; + +import org.openapitools.codegen.CodegenModel; +import org.openapitools.codegen.CodegenProperty; +import org.openapitools.codegen.DefaultCodegen; +import org.openapitools.codegen.SupportingFile; +import org.openapitools.codegen.model.ModelMap; +import org.openapitools.codegen.model.ModelsMap; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Language-agnostic utility for the {@code useEnumValueInterface} code-generation option. + * + *

When enabled, every generated enum (both top-level schema enums and inline property enums) + * is made to implement a common {@code ValuedEnum} interface that exposes the backing value, + * allowing generic code to access enum values without reflection.

+ * + *

Two entry points are provided, one for each generator lifecycle hook: + *

    + *
  • {@link #setupInPreprocessOpenAPI} — called from {@code preprocessOpenAPI} to register + * the supporting file and the import mapping.
  • + *
  • {@link #injectInPostProcessModelsEnum} — called from {@code postProcessModelsEnum} to + * inject the interface into every enum model's implements vendor extension.
  • + *
+ * + *

The only language-specific knobs are:

+ *
    + *
  • The name of the vendor extension that carries implemented interfaces + * ({@code "x-implements"} for Java, {@code "x-kotlin-implements"} for Kotlin).
  • + *
  • The output file name ({@code "ValuedEnum.java"} vs {@code "ValuedEnum.kt"}).
  • + *
+ * + *

Used by both {@link SpringCodegen} and {@link KotlinSpringServerCodegen}.

+ */ +public final class EnumValueInterfaceUtils { + + private EnumValueInterfaceUtils() {} + + /** + * Registers the {@code ValuedEnum} supporting file and import mapping. + * + *

Must be called from {@code preprocessOpenAPI} when {@code useEnumValueInterface} is + * enabled. Returns the simple class name derived from the (possibly custom) import mapping, + * which the caller must store and pass to {@link #injectInPostProcessModelsEnum} later.

+ * + * @param importMapping the codegen's import-mapping map (mutated in place) + * @param additionalProperties the codegen's additional-properties map (mutated in place) + * @param supportingFiles the codegen's supporting-files list (mutated in place) + * @param sourceFolder language source folder (e.g. {@code "src/main/java"}) + * @param configPackage the config package where the interface is generated + * (e.g. {@code "org.openapitools.configuration"}) + * @param mustacheTemplate template name (e.g. {@code "enumValueInterface.mustache"}) + * @param outputFileName generated file name (e.g. {@code "ValuedEnum.java"}) + * @return the simple class name of {@code ValuedEnum} (accounts for custom import mappings) + */ + public static String setupInPreprocessOpenAPI( + Map importMapping, + Map additionalProperties, + List supportingFiles, + String sourceFolder, + String configPackage, + String mustacheTemplate, + String outputFileName) { + + boolean customMapping = importMapping.containsKey("ValuedEnum"); + importMapping.putIfAbsent("ValuedEnum", configPackage + ".ValuedEnum"); + if (!customMapping) { + supportingFiles.add(new SupportingFile(mustacheTemplate, + (sourceFolder + File.separator + configPackage).replace(".", File.separator), + outputFileName)); + } + String fqn = importMapping.get("ValuedEnum"); + String className = fqn.substring(fqn.lastIndexOf('.') + 1); + additionalProperties.put("useEnumValueInterface", true); + return className; + } + + /** + * Injects {@code ValuedEnum} into the implements vendor extension of every enum in the + * given model batch. + * + *

Must be called from {@code postProcessModelsEnum} when {@code useEnumValueInterface} is + * enabled. Handles both top-level enum schemas and inline enum properties.

+ * + * @param objs the model batch being post-processed + * @param valuedEnumClassName simple class name (e.g. {@code "ValuedEnum"}) + * @param valuedEnumFqn fully-qualified name used for the import statement + * @param xImplementsExtensionKey vendor-extension key that carries the implements list + * ({@code "x-implements"} for Java, + * {@code "x-kotlin-implements"} for Kotlin) + */ + public static void injectInPostProcessModelsEnum( + ModelsMap objs, + String valuedEnumClassName, + String valuedEnumFqn, + String xImplementsExtensionKey) { + + List> imports = objs.getImports(); + for (ModelMap mo : objs.getModels()) { + CodegenModel cm = mo.getModel(); + boolean needsImport = false; + + if (cm.isEnum && cm.allowableValues != null) { + List xImpl = new ArrayList<>( + DefaultCodegen.getObjectAsStringList(cm.getVendorExtensions().get(xImplementsExtensionKey))); + xImpl.add(valuedEnumClassName + "<" + cm.dataType + ">"); + cm.getVendorExtensions().put(xImplementsExtensionKey, xImpl); + needsImport = true; + } + + for (CodegenProperty var : cm.vars) { + if (var.isEnum && !var.isContainer) { + List xVarImpl = new ArrayList<>( + DefaultCodegen.getObjectAsStringList(var.getVendorExtensions().get(xImplementsExtensionKey))); + xVarImpl.add(valuedEnumClassName + "<" + var.dataType + ">"); + var.getVendorExtensions().put(xImplementsExtensionKey, xVarImpl); + needsImport = true; + } + } + + if (needsImport) { + cm.imports.add(valuedEnumFqn); + Map importItem = new HashMap<>(); + importItem.put("import", valuedEnumFqn); + imports.add(importItem); + } + } + } +} diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index 30843f29a948..ea9c15b08590 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java @@ -22,9 +22,6 @@ import com.samskivert.mustache.Template; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; -import io.swagger.v3.oas.models.PathItem; -import io.swagger.v3.oas.models.media.Schema; -import io.swagger.v3.oas.models.parameters.Parameter; import lombok.Getter; import lombok.Setter; import org.openapitools.codegen.*; @@ -181,6 +178,8 @@ public String getDescription() { @Setter private boolean substituteGenericPagedModel = false; @Setter private boolean useSealedResponseInterfaces = false; @Setter private boolean companionObject = false; + @Setter private boolean useEnumValueInterface = false; + private String valuedEnumClassName = "ValuedEnum"; @Getter @Setter protected boolean useDeductionForOneOfInterfaces = false; @@ -308,6 +307,7 @@ public KotlinSpringServerCodegen() { substituteGenericPagedModel); addSwitch(COMPANION_OBJECT, "Whether to generate companion objects in data classes, enabling companion extensions.", companionObject); cliOptions.add(CliOption.newBoolean(CodegenConstants.USE_DEDUCTION_FOR_ONE_OF_INTERFACES, CodegenConstants.USE_DEDUCTION_FOR_ONE_OF_INTERFACES_DESC, useDeductionForOneOfInterfaces)); + addSwitch(CodegenConstants.USE_ENUM_VALUE_INTERFACE, CodegenConstants.USE_ENUM_VALUE_INTERFACE_DESC, useEnumValueInterface); supportedLibraries.put(SPRING_BOOT, "Spring-boot Server application."); supportedLibraries.put(SPRING_CLOUD_LIBRARY, "Spring-Cloud-Feign client with Spring-Boot auto-configured settings."); @@ -753,6 +753,10 @@ public void processOpts() { this.setSubstituteGenericPagedModel(convertPropertyToBoolean(SUBSTITUTE_GENERIC_PAGED_MODEL)); } writePropertyBack(SUBSTITUTE_GENERIC_PAGED_MODEL, substituteGenericPagedModel); + if (additionalProperties.containsKey(CodegenConstants.USE_ENUM_VALUE_INTERFACE)) { + this.setUseEnumValueInterface(convertPropertyToBoolean(CodegenConstants.USE_ENUM_VALUE_INTERFACE)); + } + writePropertyBack(CodegenConstants.USE_ENUM_VALUE_INTERFACE, useEnumValueInterface); if (isUseSpringBoot3() && isUseSpringBoot4()) { throw new IllegalArgumentException("Choose between Spring Boot 3 and Spring Boot 4"); } @@ -1154,6 +1158,13 @@ public void preprocessOpenAPI(OpenAPI openAPI) { } } + if (useEnumValueInterface) { + valuedEnumClassName = EnumValueInterfaceUtils.setupInPreprocessOpenAPI( + importMapping, additionalProperties, supportingFiles, + sourceFolder, configPackage, + "enumValueInterface.mustache", "ValuedEnum.kt"); + } + if (!additionalProperties.containsKey(TITLE)) { // The purpose of the title is for: // - README documentation @@ -1457,6 +1468,12 @@ public ModelsMap postProcessModelsEnum(ModelsMap objs) { } } + if (useEnumValueInterface) { + EnumValueInterfaceUtils.injectInPostProcessModelsEnum( + objs, valuedEnumClassName, importMapping.get("ValuedEnum"), + VendorExtension.X_KOTLIN_IMPLEMENTS.getName()); + } + return objs; } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java index b51115ef4fcd..0803f5e19eb6 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java @@ -196,6 +196,8 @@ public enum RequestMappingMode { @Setter protected boolean generateSortValidation = false; @Setter protected boolean generatePageableConstraintValidation = false; @Setter protected boolean substituteGenericPagedModel = false; + @Setter protected boolean useEnumValueInterface = false; + private String valuedEnumClassName = "ValuedEnum"; // Map from schema name to detected paged-model info (populated when substituteGenericPagedModel=true) private Map pagedModelRegistry = new HashMap<>(); @@ -378,6 +380,9 @@ public SpringCodegen() { + "PagedModel. By default this uses a generated type in the config package (default 'org.openapitools.configuration'), but `importMappings.PagedModel` can override it to a custom/FQCN-mapped type. The detected page schemas and the pagination metadata " + "schema are suppressed from code generation.", substituteGenericPagedModel)); + cliOptions.add(CliOption.newBoolean(CodegenConstants.USE_ENUM_VALUE_INTERFACE, + CodegenConstants.USE_ENUM_VALUE_INTERFACE_DESC, + useEnumValueInterface)); } @@ -588,6 +593,7 @@ public void processOpts() { convertPropertyToBooleanAndWriteBack(ADDITIONAL_NOT_NULL_ANNOTATIONS, this::setAdditionalNotNullAnnotations); convertPropertyToBooleanAndWriteBack(SUBSTITUTE_GENERIC_PAGED_MODEL, this::setSubstituteGenericPagedModel); + convertPropertyToBooleanAndWriteBack(CodegenConstants.USE_ENUM_VALUE_INTERFACE, this::setUseEnumValueInterface); if (SPRING_BOOT.equals(library)) { convertPropertyToBooleanAndWriteBack(AUTO_X_SPRING_PAGINATED, this::setAutoXSpringPaginated); @@ -886,6 +892,13 @@ public void preprocessOpenAPI(OpenAPI openAPI) { } } + if (useEnumValueInterface) { + valuedEnumClassName = EnumValueInterfaceUtils.setupInPreprocessOpenAPI( + importMapping, additionalProperties, supportingFiles, + sourceFolder, configPackage, + "enumValueInterface.mustache", "ValuedEnum.java"); + } + /* * TODO the following logic should not need anymore in OAS 3.0 if * ("/".equals(swagger.getBasePath())) { swagger.setBasePath(""); } @@ -1442,7 +1455,7 @@ public ModelsMap postProcessModelsEnum(ModelsMap objs) { for (CodegenProperty var : cm.vars) { addNullableImports = isAddNullableImports(cm, addNullableImports, var); } - if (Boolean.TRUE.equals(cm.isEnum) && cm.allowableValues != null) { + if (cm.isEnum && cm.allowableValues != null) { cm.imports.add(importMapping.get("JsonValue")); final Map item = new HashMap<>(); item.put("import", importMapping.get("JsonValue")); @@ -1455,6 +1468,12 @@ public ModelsMap postProcessModelsEnum(ModelsMap objs) { } } + if (useEnumValueInterface) { + EnumValueInterfaceUtils.injectInPostProcessModelsEnum( + objs, valuedEnumClassName, importMapping.get("ValuedEnum"), + CodegenConstants.X_IMPLEMENTS); + } + return objs; } diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/enumValueInterface.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/enumValueInterface.mustache new file mode 100644 index 000000000000..7f16c7a03146 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/JavaSpring/enumValueInterface.mustache @@ -0,0 +1,6 @@ +package {{configPackage}}; + +{{>generatedAnnotation}} +public interface ValuedEnum { + T getValue(); +} diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache index 1c7b263517fe..7391dbc29b30 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache @@ -48,7 +48,7 @@ * {{{description}}} * Values: {{#allowableValues}}{{#enumVars}}{{&name}}{{^-last}},{{/-last}}{{/enumVars}}{{/allowableValues}} */ - enum class {{{nameInPascalCase}}}(@get:JsonValue val value: {{#isContainer}}{{#items}}{{{dataType}}}{{/items}}{{/isContainer}}{{^isContainer}}{{{dataType}}}{{/isContainer}}) { + enum class {{{nameInPascalCase}}}(@get:JsonValue {{#useEnumValueInterface}}override {{/useEnumValueInterface}}val value: {{#isContainer}}{{#items}}{{{dataType}}}{{/items}}{{/isContainer}}{{^isContainer}}{{{dataType}}}{{/isContainer}}) {{#vendorExtensions.x-kotlin-implements}}{{#-first}}: {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}} {{/vendorExtensions.x-kotlin-implements}}{ {{#allowableValues}}{{#enumVars}} {{{name}}}({{{value}}}){{^-last}},{{/-last}}{{/enumVars}}{{/allowableValues}}; diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/enumClass.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/enumClass.mustache index 30916567a540..d22051e2367a 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/enumClass.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/enumClass.mustache @@ -2,7 +2,7 @@ * {{{description}}} * Values: {{#allowableValues}}{{#enumVars}}{{&name}}{{^-last}},{{/-last}}{{/enumVars}}{{/allowableValues}} */ -enum class {{classname}}(@get:JsonValue val value: {{dataType}}) {{#vendorExtensions.x-kotlin-implements}}{{#-first}}: {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}} {{/vendorExtensions.x-kotlin-implements}}{ +enum class {{classname}}(@get:JsonValue {{#useEnumValueInterface}}override {{/useEnumValueInterface}}val value: {{dataType}}) {{#vendorExtensions.x-kotlin-implements}}{{#-first}}: {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}} {{/vendorExtensions.x-kotlin-implements}}{ {{#allowableValues}}{{#enumVars}} {{&name}}({{{value}}}){{^-last}},{{/-last}}{{/enumVars}}{{/allowableValues}}; diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/enumValueInterface.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/enumValueInterface.mustache new file mode 100644 index 000000000000..a59700b58bbe --- /dev/null +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/enumValueInterface.mustache @@ -0,0 +1,6 @@ +package {{configPackage}} + +{{>generatedAnnotation}} +interface ValuedEnum { + val value: T +} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index f9d37a199c66..8c6977a088f7 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java @@ -67,9 +67,7 @@ import static org.openapitools.codegen.languages.SpringCodegen.*; import static org.openapitools.codegen.languages.features.DocumentationProviderFeatures.ANNOTATION_LIBRARY; import static org.openapitools.codegen.languages.features.DocumentationProviderFeatures.DOCUMENTATION_PROVIDER; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertNotNull; -import static org.testng.Assert.fail; +import static org.testng.Assert.*; public class SpringCodegenTest { @@ -7837,4 +7835,75 @@ void disableDiscriminatorJsonIgnorePropertiesIsTrueThenJsonIgnorePropertiesShoul JavaFileAssert.assertThat(files.get("BaseConfiguration.java")) .assertTypeAnnotations().containsWithName("JsonIgnoreProperties"); } + + // useEnumValueInterface tests + // ------------------------------------------------------------------------- + + @Test + public void useEnumValueInterface_isDisabledByDefault() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", SPRING_BOOT, new HashMap<>()); + + assertThat(files).doesNotContainKey("ValuedEnum.java"); + JavaFileAssert.assertThat(files.get("OrderStatus.java")) + .fileDoesNotContain("implements ValuedEnum"); + } + + @Test + public void useEnumValueInterface_generatesInterface() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", SPRING_BOOT, + Map.of(USE_ENUM_VALUE_INTERFACE, "true")); + + assertThat(files).containsKey("ValuedEnum.java"); + JavaFileAssert.assertThat(files.get("ValuedEnum.java")) + .isInterface() + .fileContains("interface ValuedEnum"); + } + + @Test + public void useEnumValueInterface_topLevelEnumImplementsInterface() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", SPRING_BOOT, + Map.of(USE_ENUM_VALUE_INTERFACE, "true")); + + JavaFileAssert.assertThat(files.get("OrderStatus.java")) + .fileContains("implements ValuedEnum") + .hasImports("org.openapitools.configuration.ValuedEnum"); + } + + @Test + public void useEnumValueInterface_inlineEnumImplementsInterface() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", SPRING_BOOT, + Map.of(USE_ENUM_VALUE_INTERFACE, "true")); + + JavaFileAssert.assertThat(files.get("Order.java")) + .fileContains("implements ValuedEnum") + .hasImports("org.openapitools.configuration.ValuedEnum"); + } + + @Test + public void useEnumValueInterface_noFileGeneratedWithCustomImportMapping() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", SPRING_BOOT, + Map.of(USE_ENUM_VALUE_INTERFACE, "true"), + configurator -> configurator + .addImportMapping("ValuedEnum", "com.example.custom.ValuedEnum")); + + assertThat(files).doesNotContainKey("ValuedEnum.java"); + } + + @Test + public void useEnumValueInterface_customImportMappingUsedInGeneratedCode() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", SPRING_BOOT, + Map.of(USE_ENUM_VALUE_INTERFACE, "true"), + configurator -> configurator + .addImportMapping("ValuedEnum", "com.example.custom.ValuedEnum")); + + JavaFileAssert.assertThat(files.get("OrderStatus.java")) + .fileContains("implements ValuedEnum") + .hasImports("com.example.custom.ValuedEnum"); + } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index cb2e9545272e..ad9489ba06a7 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -45,6 +45,7 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; +import static org.openapitools.codegen.CodegenConstants.USE_ENUM_VALUE_INTERFACE; import static org.openapitools.codegen.TestUtils.assertFileContains; import static org.openapitools.codegen.TestUtils.assertFileNotContains; import static org.openapitools.codegen.languages.KotlinSpringServerCodegen.*; @@ -6231,4 +6232,76 @@ public void testSealedResponseInterfacesWithDeclarativeHttpInterface() throws IO "fun getUser(", "): ResponseEntity"); } + + // useEnumValueInterface tests + // ------------------------------------------------------------------------- + + @Test + public void useEnumValueInterface_isDisabledByDefault() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", new HashMap<>()); + + assertThat(files).doesNotContainKey("ValuedEnum.kt"); + assertFileNotContains(files.get("OrderStatus.kt").toPath(), ": ValuedEnum<"); + } + + @Test + public void useEnumValueInterface_generatesInterface() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", + Map.of(USE_ENUM_VALUE_INTERFACE, "true")); + + assertThat(files).containsKey("ValuedEnum.kt"); + assertFileContains(files.get("ValuedEnum.kt").toPath(), "interface ValuedEnum"); + } + + @Test + public void useEnumValueInterface_topLevelEnumImplementsInterface() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", + Map.of(USE_ENUM_VALUE_INTERFACE, "true")); + + assertFileContains(files.get("OrderStatus.kt").toPath(), + ": ValuedEnum", + "override val value", + "import org.openapitools.configuration.ValuedEnum"); + } + + @Test + public void useEnumValueInterface_inlineEnumImplementsInterface() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", + Map.of(USE_ENUM_VALUE_INTERFACE, "true")); + + assertFileContains(files.get("Order.kt").toPath(), + ": ValuedEnum", + "override val value", + "import org.openapitools.configuration.ValuedEnum"); + } + + @Test + public void useEnumValueInterface_noFileGeneratedWithCustomImportMapping() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", + Map.of(USE_ENUM_VALUE_INTERFACE, "true"), + new HashMap<>(), + configurator -> configurator + .addImportMapping("ValuedEnum", "com.example.custom.ValuedEnum")); + + assertThat(files).doesNotContainKey("ValuedEnum.kt"); + } + + @Test + public void useEnumValueInterface_customImportMappingUsedInGeneratedCode() throws IOException { + Map files = generateFromContract( + "src/test/resources/3_0/spring/enum-value-interface.yaml", + Map.of(USE_ENUM_VALUE_INTERFACE, "true"), + new HashMap<>(), + configurator -> configurator + .addImportMapping("ValuedEnum", "com.example.custom.ValuedEnum")); + + assertFileContains(files.get("OrderStatus.kt").toPath(), + ": ValuedEnum", + "import com.example.custom.ValuedEnum"); + } } diff --git a/modules/openapi-generator/src/test/resources/3_0/spring/enum-value-interface.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/enum-value-interface.yaml new file mode 100644 index 000000000000..a59e6049cccc --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/spring/enum-value-interface.yaml @@ -0,0 +1,42 @@ +openapi: 3.0.0 +info: + title: Enum Value Interface Test + version: 1.0.0 +paths: + /orders: + get: + operationId: listOrders + parameters: + - name: status + in: query + schema: + $ref: '#/components/schemas/OrderStatus' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Order' +components: + schemas: + # Top-level enum schema + OrderStatus: + type: string + enum: + - placed + - approved + - delivered + # Model with an inline enum property + Order: + type: object + properties: + id: + type: integer + format: int64 + priority: + type: string + enum: + - low + - medium + - high From 772ff0ecfb1b63c05a96679e9285bcd4eaa7bb87 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 15 May 2026 09:04:40 +0200 Subject: [PATCH 18/21] fix: remove Pageable support for non-spring-boot libraries and update related documentation --- .../openapitools/codegen/VendorExtension.java | 2 +- .../languages/KotlinSpringServerCodegen.java | 10 ++++++ .../codegen/languages/SpringCodegen.java | 11 ++++-- .../java/spring/SpringCodegenTest.java | 28 +++++++++++++++ .../spring/KotlinSpringServerCodegenTest.java | 34 +++++++++++++++++++ .../org/openapitools/api/PetController.java | 6 ++-- .../java/org/openapitools/api/PetApi.java | 9 ++--- 7 files changed, 87 insertions(+), 13 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java index bce0e2a691f4..9be773475f2c 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/VendorExtension.java @@ -12,7 +12,7 @@ public enum VendorExtension { X_IMPLEMENTS("x-implements", ExtensionLevel.MODEL, "Ability to specify interfaces that model must implements", "empty array"), X_KOTLIN_IMPLEMENTS("x-kotlin-implements", ExtensionLevel.MODEL, "Ability to specify interfaces that model must implement", "empty array"), X_KOTLIN_IMPLEMENTS_FIELDS("x-kotlin-implements-fields", ExtensionLevel.MODEL, "Specify attributes that are implemented by the interface(s) added via `x-kotlin-implements`", "empty array"), - X_SPRING_PAGINATED("x-spring-paginated", ExtensionLevel.OPERATION, "Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object.", "false"), + X_SPRING_PAGINATED("x-spring-paginated", ExtensionLevel.OPERATION, "Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object. Only applies when `library=spring-boot`; ignored for client libraries (spring-cloud, spring-declarative-http-interface).", "false"), X_SPRING_API_VERSION("x-spring-api-version", ExtensionLevel.OPERATION, "Value for 'version' attribute in @RequestMapping (for Spring 7 and above).", null), X_SPRING_PROVIDE_ARGS("x-spring-provide-args", ExtensionLevel.OPERATION, "Allows adding additional hidden parameters in the API specification to allow access to content such as header values or properties", "empty array"), X_DISCRIMINATOR_VALUE("x-discriminator-value", ExtensionLevel.MODEL, "Used with model inheritance to specify value for discriminator that identifies current model", ""), diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index ea9c15b08590..8736ca361d9b 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java @@ -1046,6 +1046,16 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation CodegenOperation codegenOperation = super.fromOperation(path, httpMethod, operation, servers); + // For client libraries (spring-cloud, spring-declarative-http-interface) x-spring-paginated is not supported: + // they need explicit query parameters for HTTP calls, not a Pageable object. + // Strip the extension so the template does not render Pageable, and log it. + if (!SPRING_BOOT.equals(library) && codegenOperation.vendorExtensions.remove("x-spring-paginated") != null) { + LOGGER.debug("x-spring-paginated on operation '{}' is ignored for library '{}'; " + + "Pageable is only supported for spring-boot. " + + "Individual page/size/sort query parameters will be used instead.", + codegenOperation.operationId, library); + } + // Only process x-spring-paginated for server-side libraries (spring-boot) // Client libraries (spring-cloud, spring-declarative-http-interface) need actual query parameters for HTTP requests if (SPRING_BOOT.equals(library)) { diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java index 0803f5e19eb6..4c3ba5d05039 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java @@ -1244,9 +1244,16 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation // add org.springframework.format.annotation.DateTimeFormat when needed codegenOperation.allParams.stream().filter(p -> p.isDate || p.isDateTime).findFirst() .ifPresent(p -> codegenOperation.imports.add("DateTimeFormat")); + // For client libraries (spring-cloud, spring-http-interface) x-spring-paginated is not supported: + // they need explicit query parameters for HTTP calls, not a Pageable object. + // Strip the extension so the template does not render @ParameterObject Pageable, and log it. + if (!SPRING_BOOT.equals(library) && codegenOperation.vendorExtensions.remove("x-spring-paginated") != null) { + LOGGER.debug("x-spring-paginated on operation '{}' is ignored for library '{}'; " + + "Pageable is only supported for spring-boot. " + + "Individual page/size/sort query parameters will be used instead.", + codegenOperation.operationId, library); + } // add org.springframework.data.domain.Pageable import when needed - // Only for spring-boot: client libraries (spring-cloud, spring-declarative-http-interface) - // need actual query parameters for HTTP calls, so x-spring-paginated is ignored for them. if (SPRING_BOOT.equals(library) && codegenOperation.vendorExtensions.containsKey("x-spring-paginated")) { codegenOperation.imports.add("Pageable"); SpringPageableScanUtils.applySpringDocPageableAnnotation(codegenOperation, diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index 8c6977a088f7..1bd6f60d5742 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java @@ -6884,6 +6884,34 @@ public void autoXSpringPaginatedOnlyForSpringBoot() throws IOException { } } + @Test + public void explicitXSpringPaginatedIgnoredForSpringCloud() throws IOException { + // When x-spring-paginated: true is set explicitly in the spec but the library is spring-cloud, + // the extension must be stripped so the template does not emit "@ParameterObject Pageable pageable". + // Instead, individual page/size/sort @RequestParam args from the spec should remain. + Map props = new HashMap<>(); + props.put(SpringCodegen.INTERFACE_ONLY, "true"); + props.put(SpringCodegen.DOCUMENTATION_PROVIDER, "springdoc"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-with-spring-pageable.yaml", "spring-cloud", props); + + JavaFileAssert petApi = JavaFileAssert.assertThat(files.get("PetApi.java")); + + // No Pageable type, @ParameterObject annotation, or their imports must appear for spring-cloud + petApi.fileDoesNotContain("Pageable pageable", "@ParameterObject") + .hasNoImports( + "org.springframework.data.domain.Pageable", + "org.springdoc.core.annotations.ParameterObject"); + + // findPetsByStatus has only the 'status' param from the spec (no Pageable added) + petApi.assertMethod("findPetsByStatus", "List"); + + // findPetsByTags retains all individual query params defined alongside x-spring-paginated + // (page, size, sort remain; header 'size' also stays) + petApi.assertMethod("findPetsByTags", "List", "Integer", "Integer", "String", "String"); + } + @Test public void autoXSpringPaginatedDisabledByDefault() throws IOException { Map props = new HashMap<>(); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index ad9489ba06a7..761e9fefe52b 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -4899,6 +4899,40 @@ public void autoXSpringPaginatedOnlyForSpringBoot() throws Exception { } } + @Test + public void explicitXSpringPaginatedIgnoredForSpringCloud() throws Exception { + // When x-spring-paginated: true is set explicitly in the spec but the library is spring-cloud, + // the extension must be stripped so the template does not emit "pageable: Pageable". + // Individual page/size/sort @RequestParam args from the spec should remain. + Map additionalProperties = new HashMap<>(); + additionalProperties.put(USE_TAGS, "true"); + additionalProperties.put(DOCUMENTATION_PROVIDER, "springdoc"); + additionalProperties.put(INTERFACE_ONLY, "true"); + additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-with-spring-pageable.yaml", + additionalProperties, + new HashMap<>(), + configurator -> configurator.setLibrary("spring-cloud") + ); + + File petApi = files.get("PetApi.kt"); + Assert.assertNotNull(petApi, "PetApi.kt should be generated for spring-cloud library"); + + // No Pageable type or its import must appear for spring-cloud + assertFileNotContains(petApi.toPath(), + "import org.springframework.data.domain.Pageable", + "pageable: Pageable"); + + // findPetsByStatus must exist without a Pageable parameter + assertFileContains(petApi.toPath(), "fun findPetsByStatus("); + + // findPetsByTags must retain all individual query params defined alongside x-spring-paginated + assertFileContains(petApi.toPath(), "@RequestParam(value = \"page\""); + assertFileContains(petApi.toPath(), "@RequestParam(value = \"sort\""); + } + @Test public void autoXSpringPaginatedDisabledByDefault() throws Exception { Map additionalProperties = new HashMap<>(); diff --git a/samples/client/petstore/spring-cloud-tags/src/main/java/org/openapitools/api/PetController.java b/samples/client/petstore/spring-cloud-tags/src/main/java/org/openapitools/api/PetController.java index 1e637f43a1a8..78cd3c0d5fec 100644 --- a/samples/client/petstore/spring-cloud-tags/src/main/java/org/openapitools/api/PetController.java +++ b/samples/client/petstore/spring-cloud-tags/src/main/java/org/openapitools/api/PetController.java @@ -125,8 +125,7 @@ ResponseEntity deletePet( produces = { "application/json", "application/xml" } ) ResponseEntity> findPetsByStatus( - @NotNull @Parameter(name = "status", description = "Status values that need to be considered for filter", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "status", required = true) List status, - @ParameterObject final Pageable pageable + @NotNull @Parameter(name = "status", description = "Status values that need to be considered for filter", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "status", required = true) List status ); @@ -164,8 +163,7 @@ ResponseEntity> findPetsByStatus( produces = { "application/json", "application/xml" } ) ResponseEntity> findPetsByTags( - @NotNull @Parameter(name = "tags", description = "Tags to filter by", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "tags", required = true) List tags, - @ParameterObject final Pageable pageable + @NotNull @Parameter(name = "tags", description = "Tags to filter by", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "tags", required = true) List tags ); diff --git a/samples/openapi3/client/petstore/spring-cloud-spring-pageable/src/main/java/org/openapitools/api/PetApi.java b/samples/openapi3/client/petstore/spring-cloud-spring-pageable/src/main/java/org/openapitools/api/PetApi.java index 4949b2dae998..6d015c8e2890 100644 --- a/samples/openapi3/client/petstore/spring-cloud-spring-pageable/src/main/java/org/openapitools/api/PetApi.java +++ b/samples/openapi3/client/petstore/spring-cloud-spring-pageable/src/main/java/org/openapitools/api/PetApi.java @@ -127,8 +127,7 @@ ResponseEntity deletePet( @org.springframework.validation.annotation.Validated @org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')") ResponseEntity> findPetsByStatus( - @NotNull @Parameter(name = "status", description = "Status values that need to be considered for filter", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "status", required = true) List status, - @ParameterObject final Pageable pageable + @NotNull @Parameter(name = "status", description = "Status values that need to be considered for filter", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "status", required = true) List status ); @@ -174,8 +173,7 @@ ResponseEntity> findPetsByTags( @NotNull @Min(value = 1) @Parameter(name = "size", description = "The number of items to return per page. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "size", required = true, defaultValue = "20") Integer size2, @NotNull @Min(value = 0) @Parameter(name = "page", description = "The page to return, starting with page 0. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "page", required = true, defaultValue = "0") Integer page, @NotNull @Parameter(name = "sort", description = "The sorting to apply to the Pageable object. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "sort", required = true, defaultValue = "id,asc") String sort, - @Parameter(name = "size", description = "A test HeaderParam for issue #8315 - must NOT be removed when x-spring-paginated:true is used.", in = ParameterIn.HEADER) @RequestHeader(value = "size", required = false) @Nullable String size, - @ParameterObject final Pageable pageable + @Parameter(name = "size", description = "A test HeaderParam for issue #8315 - must NOT be removed when x-spring-paginated:true is used.", in = ParameterIn.HEADER) @RequestHeader(value = "size", required = false) @Nullable String size ); @@ -252,8 +250,7 @@ ResponseEntity getPetById( ResponseEntity> listAllPets( @Parameter(name = "page", description = "The page number to return. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", in = ParameterIn.QUERY) @Valid @RequestParam(value = "page", required = false, defaultValue = "0") Integer page, @Parameter(name = "size", description = "The number of items to return per page. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", in = ParameterIn.QUERY) @Valid @RequestParam(value = "size", required = false, defaultValue = "20") Integer size, - @Parameter(name = "sort", description = "The sort order. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", in = ParameterIn.QUERY) @Valid @RequestParam(value = "sort", required = false) @Nullable String sort, - @ParameterObject final Pageable pageable + @Parameter(name = "sort", description = "The sort order. Test QueryParam for issue #8315 - must be removed when x-spring-paginated:true is used.", in = ParameterIn.QUERY) @Valid @RequestParam(value = "sort", required = false) @Nullable String sort ); From 011c7b737ba32ce25b4a109446a33fe84221f9fc Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 15 May 2026 09:06:15 +0200 Subject: [PATCH 19/21] update documentation --- docs/generators/java-camel.md | 2 +- docs/generators/kotlin-spring.md | 2 +- docs/generators/spring.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/generators/java-camel.md b/docs/generators/java-camel.md index 9b007dbf9103..258a45450124 100644 --- a/docs/generators/java-camel.md +++ b/docs/generators/java-camel.md @@ -145,7 +145,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |x-class-extra-annotation|List of custom annotations to be added to model|MODEL|null |x-field-extra-annotation|List of custom annotations to be added to property|FIELD, OPERATION_PARAMETER|null |x-operation-extra-annotation|List of custom annotations to be added to operation|OPERATION|null -|x-spring-paginated|Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object.|OPERATION|false +|x-spring-paginated|Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object. Only applies when `library=spring-boot`; ignored for client libraries (spring-cloud, spring-declarative-http-interface).|OPERATION|false |x-version-param|Marker property that tells that this parameter would be used for endpoint versioning. Applicable for headers & query params. true/false|OPERATION_PARAMETER|null |x-pattern-message|Add this property whenever you need to customize the invalidation error message for the regex pattern of a variable|FIELD, OPERATION_PARAMETER|null |x-size-message|Add this property whenever you need to customize the invalidation error message for the size or length of a variable|FIELD, OPERATION_PARAMETER|null diff --git a/docs/generators/kotlin-spring.md b/docs/generators/kotlin-spring.md index 3d716858bc5e..090949dc1f72 100644 --- a/docs/generators/kotlin-spring.md +++ b/docs/generators/kotlin-spring.md @@ -91,7 +91,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |x-maximum-message|Add this property whenever you need to customize the invalidation error message for the maximum value of a variable|FIELD, OPERATION_PARAMETER|null |x-kotlin-implements|Ability to specify interfaces that model must implement|MODEL|empty array |x-kotlin-implements-fields|Specify attributes that are implemented by the interface(s) added via `x-kotlin-implements`|MODEL|empty array -|x-spring-paginated|Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object.|OPERATION|false +|x-spring-paginated|Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object. Only applies when `library=spring-boot`; ignored for client libraries (spring-cloud, spring-declarative-http-interface).|OPERATION|false ## IMPORT MAPPING diff --git a/docs/generators/spring.md b/docs/generators/spring.md index a247d7accebd..5af466de0cd9 100644 --- a/docs/generators/spring.md +++ b/docs/generators/spring.md @@ -138,7 +138,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |x-class-extra-annotation|List of custom annotations to be added to model|MODEL|null |x-field-extra-annotation|List of custom annotations to be added to property|FIELD, OPERATION_PARAMETER|null |x-operation-extra-annotation|List of custom annotations to be added to operation|OPERATION|null -|x-spring-paginated|Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object.|OPERATION|false +|x-spring-paginated|Add `org.springframework.data.domain.Pageable` to controller method. Can be used to handle `page`, `size` and `sort` query parameters. If these query parameters are also specified in the operation spec, they will be removed from the controller method as their values can be obtained from the `Pageable` object. Only applies when `library=spring-boot`; ignored for client libraries (spring-cloud, spring-declarative-http-interface).|OPERATION|false |x-version-param|Marker property that tells that this parameter would be used for endpoint versioning. Applicable for headers & query params. true/false|OPERATION_PARAMETER|null |x-pattern-message|Add this property whenever you need to customize the invalidation error message for the regex pattern of a variable|FIELD, OPERATION_PARAMETER|null |x-size-message|Add this property whenever you need to customize the invalidation error message for the size or length of a variable|FIELD, OPERATION_PARAMETER|null From 90282eab37170eb2a57627a2d524ccfeb3161aba Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 15 May 2026 12:30:36 +0200 Subject: [PATCH 20/21] fix: update default resolution logic for allOf schemas to last-writer-wins semantics --- .../codegen/utils/ModelUtils.java | 76 ++++++++++++------- .../codegen/utils/ModelUtilsTest.java | 71 +++++++++++++++++ 2 files changed, 119 insertions(+), 28 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index 4e261ae471da..2c40a8217c6a 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -1021,21 +1021,34 @@ public static ResolvedMinBound resolveMinimumBound(OpenAPI openAPI, Schema sc } /** - * Returns the effective {@code default} for the given schema, resolving through a top-level - * {@code $ref} and recursively walking any {@code allOf} items. - * The inline schema's default takes precedence (explicit per-endpoint override); - * falls back to the first non-null default found via depth-first search of {@code allOf} items. - * Circular {@code allOf} references are detected and skipped. - * - * @param openAPI the OpenAPI document used to resolve {@code $ref}s - * @param schema the schema to inspect - * @return the effective default value, or {@code null} if none is defined - */ - /** - * Returns the effective {@code default} for the given schema, resolving through a top-level - * {@code $ref} and recursively walking any {@code allOf} items. - * The inline schema's default takes precedence (explicit per-endpoint override); - * falls back to the first non-null default found via depth-first search of {@code allOf} items. + * Returns the effective {@code default} for the given schema using + * last-writer-wins semantics across the flattened {@code allOf} chain. + * + *

Resolution algorithm

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

Example

+ *
{@code
+     * # Base1: default = "base_1"   → Step 1: candidate = "base_1"
+     * # Base2: default = "base_2"   → Step 2: candidate = "base_2"  (overwrites Step 1)
+     * #
+     * # Intermediate:
+     * #   allOf: [$ref Base1, $ref Base2]
+     * #   → resolves to "base_2"  (Base2 is last, wins over Base1)
+     * #
+     * # Final:
+     * #   allOf:
+     * #     - $ref: Intermediate    → Step 3a: candidate = "base_2"
+     * #     - default: "final"      → Step 3b: candidate = "final"  (overwrites Step 3a)
+     * #   → resolves to "final"
+     * }
* * @param openAPI the OpenAPI document used to resolve {@code $ref}s * @param schema the schema to inspect @@ -1045,27 +1058,34 @@ public static Object resolveDefault(OpenAPI openAPI, Schema schema) { schema = getReferencedSchema(openAPI, schema); if (schema == null) return null; - Object defaultValue = schema.getDefault(); - if (defaultValue != null) { - // inline default value takes precedence - return defaultValue; + // Direct default short-circuits — no allOf traversal needed. + Object directDefault = schema.getDefault(); + if (directDefault != null) { + return directDefault; } + + // Walk allOf top-to-bottom; each non-null result overwrites the previous candidate. if (hasAllOf(schema)) { - return getFirstNonNullDefault(openAPI, schema).orElse(null); + return getLastNonNullDefault(openAPI, schema); } return null; } /** - * Recursively searches {@code allOf} items for the first non-null {@code default} value. - * The first non-null default in {@code allOf} wins. - * This behavior is arbitrary and may not reflect intentions of the spec creator, as default's inheritance/overriding behavior is undefined. + * Walks {@code allOf} items top-to-bottom and returns the last non-null + * {@code default} value found (last-writer-wins). Each item is fully resolved + * (including its own nested {@code allOf}) before the candidate is updated, so + * arbitrarily deep chains are flattened correctly. */ - private static @NonNull Optional getFirstNonNullDefault(OpenAPI openAPI, Schema schema) { - return schema.getAllOf().stream() - .map(item -> resolveDefault(openAPI, item)) - .filter(Objects::nonNull) - .findFirst(); + private static Object getLastNonNullDefault(OpenAPI openAPI, Schema schema) { + Object last = null; + for (Schema item : schema.getAllOf()) { + Object resolved = resolveDefault(openAPI, item); + if (resolved != null) { + last = resolved; + } + } + return last; } public static boolean hasValidation(Schema sc) { diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java index c9d45e598941..c9259421bb8a 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java @@ -1273,4 +1273,75 @@ public void resolveDefault_nestedAllOf_findsDefaultInNestedItem() { assertEquals(ModelUtils.resolveDefault(openAPI, top), 99); } + + @Test + public void resolveDefault_allOf_lastDefaultWins() { + // allOf: [Base1(default="base_1"), Base2(default="base_2")] + // Base2 is last → wins + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema base1 = new StringSchema(); + base1.setDefault("base_1"); + openAPI.getComponents().addSchemas("Base1", base1); + + Schema base2 = new StringSchema(); + base2.setDefault("base_2"); + openAPI.getComponents().addSchemas("Base2", base2); + + Schema schema = new Schema<>().allOf(List.of( + new Schema<>().$ref("#/components/schemas/Base1"), + new Schema<>().$ref("#/components/schemas/Base2") + )); + assertEquals(ModelUtils.resolveDefault(openAPI, schema), "base_2"); + } + + @Test + public void resolveDefault_allOf_lastNonNullDefaultWins() { + // allOf: [Base1(default="base_1"), NoDefault] — trailing null item does not clear candidate + OpenAPI openAPI = TestUtils.createOpenAPI(); + Schema base1 = new StringSchema(); + base1.setDefault("base_1"); + openAPI.getComponents().addSchemas("Base1", base1); + + Schema schema = new Schema<>().allOf(List.of( + new Schema<>().$ref("#/components/schemas/Base1"), + new StringSchema() // no default + )); + assertEquals(ModelUtils.resolveDefault(openAPI, schema), "base_1"); + } + + @Test + public void resolveDefault_fullChain_lastWriterWins() { + // Mirrors the documented resolution example: + // Base1: default="base_1" + // Base2: default="base_2" + // Intermediate: allOf: [Base1, Base2] → resolves to "base_2" (Base2 is last) + // Final: allOf: [Intermediate, {default:"final"}] → resolves to "final" (inline patch is last) + OpenAPI openAPI = TestUtils.createOpenAPI(); + + Schema base1 = new StringSchema(); + base1.setDefault("base_1"); + openAPI.getComponents().addSchemas("Base1", base1); + + Schema base2 = new StringSchema(); + base2.setDefault("base_2"); + openAPI.getComponents().addSchemas("Base2", base2); + + Schema intermediate = new Schema<>().allOf(List.of( + new Schema<>().$ref("#/components/schemas/Base1"), + new Schema<>().$ref("#/components/schemas/Base2") + )); + openAPI.getComponents().addSchemas("Intermediate", intermediate); + + Schema inlinePatch = new StringSchema(); + inlinePatch.setDefault("final"); + Schema finalSchema = new Schema<>().allOf(List.of( + new Schema<>().$ref("#/components/schemas/Intermediate"), + inlinePatch + )); + + // Intermediate alone resolves to "base_2" + assertEquals(ModelUtils.resolveDefault(openAPI, new Schema<>().$ref("#/components/schemas/Intermediate")), "base_2"); + // Full chain resolves to "final" + assertEquals(ModelUtils.resolveDefault(openAPI, finalSchema), "final"); + } } From 94ed6c5021efabd4218f1e818b29e717afdc4a3c Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Fri, 15 May 2026 12:39:11 +0200 Subject: [PATCH 21/21] fix: ensure correct parent reference ordering in allOf for default resolution --- .../codegen/OpenAPINormalizer.java | 8 ++- .../codegen/utils/ModelUtils.java | 4 ++ .../codegen/OpenAPINormalizerTest.java | 58 +++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java index 418b3c0ff2b5..9a6ee4e2542c 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java @@ -1757,8 +1757,14 @@ protected void ensureInheritanceForDiscriminatorMapping(Schema parent, Schema ch // already done, so no need to add return; } + // Prepend at position 0 so the parent ref is the FIRST item in the child's allOf. + // resolveDefault() uses last-writer-wins semantics, so anything the child already + // expressed in its allOf (including its own defaults) must come after the base in + // order to win. Appending at the end would make the parent the last — and therefore + // the winning — default, which is wrong: the normalizer is injecting structural + // inheritance here, not overriding child-specified defaults. Schema refToParent = new Schema<>().$ref(reference); - allOf.add(refToParent); + allOf.add(0, refToParent); } else { allOf = new ArrayList<>(); child.setAllOf(allOf); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index 2c40a8217c6a..ca2e235b52b6 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -1053,6 +1053,10 @@ public static ResolvedMinBound resolveMinimumBound(OpenAPI openAPI, Schema sc * @param openAPI the OpenAPI document used to resolve {@code $ref}s * @param schema the schema to inspect * @return the effective default value, or {@code null} if none is defined + * @implNote The result depends on the {@code allOf} array ordering as it exists when + * this method is called — i.e., after the OpenAPI normalizer has run. + * Normalizer mutations (e.g. {@code ensureInheritanceForDiscriminatorMapping} prepending + * a parent {@code $ref}) are therefore visible here and are accounted for by design. */ public static Object resolveDefault(OpenAPI openAPI, Schema schema) { schema = getReferencedSchema(openAPI, schema); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java index 2e25bb53b85c..171d98d415d0 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java @@ -1544,4 +1544,62 @@ public void oneOf_issue_23276() { assertNotNull(payload.getOneOf()); } + @Test + public void ensureInheritanceForDiscriminatorMapping_prependsParentRef_childDefaultWins() { + // When a child already has allOf items (e.g. a base schema carrying "base_default"), + // the normalizer must INSERT the parent $ref at position 0 — not append at the end. + // With last-writer-wins semantics in resolveDefault(), appending at the end would make + // the parent's default the winner, incorrectly overriding the child's own default. + OpenAPI openAPI = TestUtils.createOpenAPI(); + + Schema parent = new StringSchema(); + parent.setDefault("parent_default"); + + Schema base = new StringSchema(); + base.setDefault("base_default"); + openAPI.getComponents().addSchemas("Base", base); + + // Child: allOf → [Base]; no Parent ref yet — use mutable list (parser always produces ArrayList) + Schema child = new Schema<>().allOf(new ArrayList<>(List.of(new Schema<>().$ref("#/components/schemas/Base")))); + openAPI.getComponents().addSchemas("Child", child); + openAPI.getComponents().addSchemas("Parent", parent); + + OpenAPINormalizer normalizer = new OpenAPINormalizer(openAPI, Map.of()); + normalizer.ensureInheritanceForDiscriminatorMapping(parent, child, "Parent", new HashSet<>()); + + // Parent ref must be prepended at index 0; Base ref stays at index 1 + assertEquals(((Schema) child.getAllOf().get(0)).get$ref(), "#/components/schemas/Parent"); + assertEquals(((Schema) child.getAllOf().get(1)).get$ref(), "#/components/schemas/Base"); + + // resolveDefault walks top-to-bottom, last non-null wins → "base_default" + assertEquals(ModelUtils.resolveDefault(openAPI, child), "base_default"); + } + + @Test + public void ensureInheritanceForDiscriminatorMapping_noExistingAllOf_noProperties_directDefaultPreserved() { + // When a child has no allOf and no properties but does have a direct default, + // the normalizer creates allOf with just the parent $ref. The child's direct default + // must NOT be cleared — resolveDefault() short-circuits on it before inspecting allOf. + OpenAPI openAPI = TestUtils.createOpenAPI(); + + Schema parent = new StringSchema(); + parent.setDefault("parent_default"); + openAPI.getComponents().addSchemas("Parent", parent); + + // Child: direct default only, no allOf, no properties + Schema child = new StringSchema(); + child.setDefault("child_direct_default"); + openAPI.getComponents().addSchemas("Child", child); + + OpenAPINormalizer normalizer = new OpenAPINormalizer(openAPI, Map.of()); + normalizer.ensureInheritanceForDiscriminatorMapping(parent, child, "Parent", new HashSet<>()); + + // Child now has allOf (parent ref only), and still holds its direct default + assertNotNull(child.getAllOf()); + assertEquals(child.getDefault(), "child_direct_default"); + + // resolveDefault short-circuits on the direct default — child wins + assertEquals(ModelUtils.resolveDefault(openAPI, child), "child_direct_default"); + } + }