From 473c5516f621ec7bc10f53ccbeb6a1df1b3ce5cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1chym=20Metli=C4=8Dka?= Date: Fri, 10 Apr 2026 19:31:41 +0200 Subject: [PATCH 01/15] feat: add sort validation support for pageable operations --- .../languages/KotlinSpringServerCodegen.java | 105 +++++++++ .../kotlin-spring/validSort.mustache | 76 +++++++ .../spring/KotlinSpringServerCodegenTest.java | 137 ++++++++++++ .../3_0/spring/petstore-sort-validation.yaml | 202 ++++++++++++++++++ 4 files changed, 520 insertions(+) create mode 100644 modules/openapi-generator/src/main/resources/kotlin-spring/validSort.mustache create mode 100644 modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml 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 5dc081bbd3ab..2477b4b6a265 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,6 +22,9 @@ 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.*; @@ -98,6 +101,7 @@ public class KotlinSpringServerCodegen extends AbstractKotlinCodegen public static final String USE_REQUEST_MAPPING_ON_CONTROLLER = "useRequestMappingOnController"; public static final String USE_REQUEST_MAPPING_ON_INTERFACE = "useRequestMappingOnInterface"; public static final String AUTO_X_SPRING_PAGINATED = "autoXSpringPaginated"; + public static final String GENERATE_SORT_VALIDATION = "generateSortValidation"; public static final String USE_SEALED_RESPONSE_INTERFACES = "useSealedResponseInterfaces"; public static final String COMPANION_OBJECT = "companionObject"; @@ -164,6 +168,7 @@ public String getDescription() { @Setter private DeclarativeInterfaceReactiveMode declarativeInterfaceReactiveMode = DeclarativeInterfaceReactiveMode.coroutines; @Setter private boolean useResponseEntity = true; @Setter private boolean autoXSpringPaginated = false; + @Setter private boolean generateSortValidation = false; @Setter private boolean useSealedResponseInterfaces = false; @Setter private boolean companionObject = false; @@ -180,6 +185,9 @@ 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<>(); + public KotlinSpringServerCodegen() { super(); @@ -272,6 +280,7 @@ public KotlinSpringServerCodegen() { addOption(SCHEMA_IMPLEMENTS, "A map of single interface or a list of interfaces per schema name that should be implemented (serves similar purpose as `x-kotlin-implements`, but is fully decoupled from the api spec). Example: yaml `schemaImplements: {Pet: com.some.pack.WithId, Category: [com.some.pack.CategoryInterface], Dog: [com.some.pack.Canine, com.some.pack.OtherInterface]}` implements interfaces in schemas `Pet` (interface `com.some.pack.WithId`), `Category` (interface `com.some.pack.CategoryInterface`), `Dog`(interfaces `com.some.pack.Canine`, `com.some.pack.OtherInterface`)", "empty map"); addOption(SCHEMA_IMPLEMENTS_FIELDS, "A map of single field or a list of fields per schema name that should be prepended with `override` (serves similar purpose as `x-kotlin-implements-fields`, but is fully decoupled from the api spec). Example: yaml `schemaImplementsFields: {Pet: id, Category: [name, id], Dog: [bark, breed]}` marks fields to be prepended with `override` in schemas `Pet` (field `id`), `Category` (fields `name`, `id`) and `Dog` (fields `bark`, `breed`)", "empty map"); addSwitch(AUTO_X_SPRING_PAGINATED, "Automatically add x-spring-paginated to operations that have 'page', 'size', and 'sort' query parameters. When enabled, operations with all three parameters will have Pageable support automatically applied. Operations with x-spring-paginated explicitly set to false will not be auto-detected.", autoXSpringPaginated); + addSwitch(GENERATE_SORT_VALIDATION, "Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to paginated operations whose 'sort' parameter has enum values. Requires useBeanValidation=true and library=spring-boot.", generateSortValidation); addSwitch(COMPANION_OBJECT, "Whether to generate companion objects in data classes, enabling companion extensions.", companionObject); supportedLibraries.put(SPRING_BOOT, "Spring-boot Server application."); supportedLibraries.put(SPRING_CLOUD_LIBRARY, @@ -704,6 +713,10 @@ public void processOpts() { this.setAutoXSpringPaginated(convertPropertyToBoolean(AUTO_X_SPRING_PAGINATED)); } writePropertyBack(AUTO_X_SPRING_PAGINATED, autoXSpringPaginated); + if (additionalProperties.containsKey(GENERATE_SORT_VALIDATION) && library.equals(SPRING_BOOT)) { + this.setGenerateSortValidation(convertPropertyToBoolean(GENERATE_SORT_VALIDATION)); + } + writePropertyBack(GENERATE_SORT_VALIDATION, generateSortValidation); if (isUseSpringBoot3() && isUseSpringBoot4()) { throw new IllegalArgumentException("Choose between Spring Boot 3 and Spring Boot 4"); } @@ -1042,6 +1055,22 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation } // #8315 Remove matching Spring Data Web default query params if 'x-spring-paginated' with Pageable is used + // Before removal, capture sort enum values for @ValidSort if generateSortValidation is enabled + 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(", ")); + String validSortAnnotation = "@ValidSort(allowedValues = [" + allowedValuesStr + "])"; + + Object existingAnnotation = codegenOperation.vendorExtensions.get("x-operation-extra-annotation"); + List existingAnnotations = DefaultCodegen.getObjectAsStringList(existingAnnotation); + List updatedAnnotations = new ArrayList<>(existingAnnotations); + updatedAnnotations.add(validSortAnnotation); + codegenOperation.vendorExtensions.put("x-operation-extra-annotation", updatedAnnotations); + + codegenOperation.imports.add("ValidSort"); + } codegenOperation.queryParams.removeIf(param -> defaultPageableQueryParams.contains(param.baseName)); codegenOperation.allParams.removeIf(param -> param.isQueryParam && defaultPageableQueryParams.contains(param.baseName)); } @@ -1058,6 +1087,10 @@ public void preprocessOpenAPI(OpenAPI openAPI) { (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "EnumConverterConfiguration.kt")); } + if (SPRING_BOOT.equals(library) && generateSortValidation && useBeanValidation) { + scanSortValidationEnums(openAPI); + } + if (!additionalProperties.containsKey(TITLE)) { // The purpose of the title is for: // - README documentation @@ -1123,6 +1156,78 @@ public void preprocessOpenAPI(OpenAPI openAPI) { // TODO: Handle tags } + /** + * Scans the OpenAPI spec for paginated operations whose 'sort' parameter has enum values, + * builds the {@link #sortValidationEnums} registry, and registers the ValidSort.kt supporting file. + * Called from {@link #preprocessOpenAPI} when {@code generateSortValidation} is enabled. + */ + private void scanSortValidationEnums(OpenAPI openAPI) { + if (openAPI.getPaths() == null) { + return; + } + boolean foundAny = false; + for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { + for (Operation operation : pathEntry.getValue().readOperations()) { + String operationId = operation.getOperationId(); + if (operationId == null || !willBePageable(operation)) { + continue; + } + if (operation.getParameters() == null) { + continue; + } + for (Parameter param : operation.getParameters()) { + if (!"sort".equals(param.getName())) { + continue; + } + Schema schema = param.getSchema(); + if (schema == null) { + continue; + } + if (schema.get$ref() != null) { + schema = ModelUtils.getReferencedSchema(openAPI, schema); + } + if (schema == null || schema.getEnum() == null || schema.getEnum().isEmpty()) { + continue; + } + List enumValues = schema.getEnum().stream() + .map(Object::toString) + .collect(Collectors.toList()); + sortValidationEnums.put(operationId, enumValues); + foundAny = true; + } + } + } + if (foundAny) { + importMapping.putIfAbsent("ValidSort", configPackage + ".ValidSort"); + supportingFiles.add(new SupportingFile("validSort.mustache", + (sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidSort.kt")); + } + } + + /** + * Returns true if the given operation will have a Pageable parameter injected — either because + * it has {@code x-spring-paginated: true} explicitly, or because {@link #autoXSpringPaginated} + * is enabled and the operation has all three default pagination query parameters (page, size, sort). + */ + private boolean willBePageable(Operation operation) { + 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()); + return paramNames.containsAll(Arrays.asList("page", "size", "sort")); + } + return false; + } + @Override public void postProcessModelProperty(CodegenModel model, CodegenProperty property) { super.postProcessModelProperty(model, property); diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/validSort.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/validSort.mustache new file mode 100644 index 000000000000..f0066fcc788e --- /dev/null +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/validSort.mustache @@ -0,0 +1,76 @@ +package {{configPackage}} + +import {{javaxPackage}}.validation.Constraint +import {{javaxPackage}}.validation.ConstraintValidator +import {{javaxPackage}}.validation.ConstraintValidatorContext +import {{javaxPackage}}.validation.Payload +import {{javaxPackage}}.validation.constraintvalidation.SupportedValidationTarget +import {{javaxPackage}}.validation.constraintvalidation.ValidationTarget +import org.springframework.data.domain.Pageable + +/** + * Validates that sort properties in a [Pageable] parameter match the allowed values. + * + * This annotation can only be applied to methods that have a [Pageable] parameter. + * The validator checks that each sort property and direction combination in the [Pageable] + * matches one of the strings specified in [allowedValues]. + * + * Expected value format: `"property,direction"` (e.g. `"id,asc"`, `"name,desc"`). + * + * @property allowedValues The allowed sort strings (e.g. `["id,asc", "id,desc"]`) + * @property groups Validation groups (optional) + * @property payload Additional payload (optional) + * @property message Validation error message (default: "Invalid sort column") + */ +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +@Constraint(validatedBy = [SortValidator::class]) +@Target(AnnotationTarget.FUNCTION) +annotation class ValidSort( + val allowedValues: Array, + val groups: Array> = [], + val payload: Array> = [], + val message: String = "Invalid sort column" +) + +@SupportedValidationTarget(ValidationTarget.PARAMETERS) +class SortValidator : ConstraintValidator> { + + private lateinit var allowedValues: Set + + override fun initialize(constraintAnnotation: ValidSort) { + allowedValues = constraintAnnotation.allowedValues.toSet() + } + + override fun isValid(parameters: Array?, context: ConstraintValidatorContext): Boolean { + val pageable = parameters?.filterIsInstance()?.firstOrNull() + ?: throw IllegalStateException( + "@ValidSort can only be used on methods with a Pageable parameter. " + + "Ensure the annotated method has a parameter of type org.springframework.data.domain.Pageable." + ) + + val invalid = pageable.sort + .foldIndexed(emptyMap()) { index, acc, order -> + val sortValue = "${order.property},${order.direction.name.lowercase()}" + if (sortValue !in allowedValues) acc + (index to order.property) + else acc + } + .toSortedMap() + + if (invalid.isNotEmpty()) { + context.disableDefaultConstraintViolation() + invalid.forEach { (index, property) -> + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate} [$property]" + ) + .addPropertyNode("sort") + .addPropertyNode("property") + .inIterable() + .atIndex(index) + .addConstraintViolation() + } + } + + return invalid.isEmpty() + } +} 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 504583c759dd..9f7138467f21 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 @@ -4162,6 +4162,143 @@ private Map generateFromContract( // ========== AUTO X-SPRING-PAGINATED TESTS ========== + // ========== GENERATE SORT VALIDATION TESTS ========== + + @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); + + File petApi = files.get("PetApi.kt"); + assertFileContains(petApi.toPath(), "@ValidSort(allowedValues = [\"id,asc\", \"id,desc\", \"name,asc\", \"name,desc\"])"); + assertFileContains(petApi.toPath(), "import org.openapitools.configuration.ValidSort"); + } + + @Test + public void generateSortValidationAddsAnnotationForAutoDetectedPaginated() 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(AUTO_X_SPRING_PAGINATED, "true"); + + Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); + + File petApi = files.get("PetApi.kt"); + assertFileContains(petApi.toPath(), "@ValidSort(allowedValues = [\"id,asc\", \"id,desc\"])"); + } + + @Test + public void generateSortValidationHandlesRefSortEnum() 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); + + File petApi = files.get("PetApi.kt"); + assertFileContains(petApi.toPath(), "@ValidSort(allowedValues = [\"id,asc\", \"id,desc\", \"createdAt,asc\", \"createdAt,desc\"])"); + } + + @Test + public void generateSortValidationDoesNotAnnotateNonPaginatedOperation() 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); + + File petApi = files.get("PetApi.kt"); + String content = Files.readString(petApi.toPath()); + + // findPetsNonPaginatedWithSortEnum has sort enum but NO pagination — must not get @ValidSort + int methodStart = content.indexOf("fun findPetsNonPaginatedWithSortEnum("); + Assert.assertTrue(methodStart >= 0, "findPetsNonPaginatedWithSortEnum method should exist"); + String methodBlock = content.substring(Math.max(0, methodStart - 500), methodStart); + Assert.assertFalse(methodBlock.contains("@ValidSort"), + "Non-paginated operation should not have @ValidSort even if sort param has enum values"); + } + + @Test + public void generateSortValidationDoesNotAnnotateWhenSortHasNoEnum() 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); + + File petApi = files.get("PetApi.kt"); + String content = Files.readString(petApi.toPath()); + + // findPetsWithoutSortEnum has pagination but sort has NO enum values + int methodStart = content.indexOf("fun findPetsWithoutSortEnum("); + Assert.assertTrue(methodStart >= 0, "findPetsWithoutSortEnum method should exist"); + String methodBlock = content.substring(Math.max(0, methodStart - 500), methodStart); + Assert.assertFalse(methodBlock.contains("@ValidSort"), + "Paginated operation with non-enum sort should not have @ValidSort"); + } + + @Test + public void generateSortValidationGeneratesValidSortFile() 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); + + File validSortFile = files.get("ValidSort.kt"); + Assert.assertNotNull(validSortFile, "ValidSort.kt should be generated when generateSortValidation=true"); + assertFileContains(validSortFile.toPath(), "annotation class ValidSort"); + assertFileContains(validSortFile.toPath(), "class SortValidator"); + assertFileContains(validSortFile.toPath(), "val allowedValues: Array"); + assertFileContains(validSortFile.toPath(), "allowedValues.toSet()"); + } + + @Test + public void generateSortValidationDoesNotGenerateValidSortFileWhenDisabled() throws Exception { + Map additionalProperties = new HashMap<>(); + additionalProperties.put(USE_TAGS, "true"); + additionalProperties.put(INTERFACE_ONLY, "true"); + additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); + // NOT setting GENERATE_SORT_VALIDATION (defaults to false) + + Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); + + Assert.assertNull(files.get("ValidSort.kt"), "ValidSort.kt should NOT be generated when generateSortValidation=false"); + File petApi = files.get("PetApi.kt"); + assertFileNotContains(petApi.toPath(), "@ValidSort"); + } + + @Test + public void generateSortValidationDoesNotGenerateValidSortFileWhenBeanValidationDisabled() 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(USE_BEANVALIDATION, "false"); + + Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); + + Assert.assertNull(files.get("ValidSort.kt"), "ValidSort.kt should NOT be generated when useBeanValidation=false"); + File petApi = files.get("PetApi.kt"); + assertFileNotContains(petApi.toPath(), "@ValidSort"); + } + @Test public void autoXSpringPaginatedDetectsAllThreeParams() throws Exception { Map additionalProperties = new HashMap<>(); 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 new file mode 100644 index 000000000000..552607803f11 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml @@ -0,0 +1,202 @@ +openapi: 3.0.1 +info: + title: OpenAPI Petstore - Sort Validation Test + description: Test spec for generateSortValidation feature + version: 1.0.0 +servers: + - url: http://petstore.swagger.io/v2 +tags: + - name: pet + description: Everything about your Pets +paths: + /pet/findByStatusWithSort: + get: + tags: + - pet + summary: Find pets with explicit x-spring-paginated and inline sort enum + operationId: findPetsWithSortEnum + x-spring-paginated: true + parameters: + - name: status + in: query + description: Status filter + schema: + type: string + - name: page + in: query + schema: + type: integer + default: 0 + - name: size + in: query + schema: + type: integer + default: 20 + - name: sort + in: query + description: Sort order + schema: + type: string + enum: + - "id,asc" + - "id,desc" + - "name,asc" + - "name,desc" + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /pet/findAutoDetectedWithSort: + get: + tags: + - pet + summary: Find pets with auto-detected pagination and sort enum + operationId: findPetsAutoDetectedWithSort + parameters: + - name: status + in: query + description: Status filter + schema: + type: string + - name: page + in: query + schema: + type: integer + default: 0 + - name: size + in: query + schema: + type: integer + default: 20 + - name: sort + in: query + description: Sort order + schema: + type: string + enum: + - "id,asc" + - "id,desc" + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /pet/findWithRefSort: + get: + tags: + - pet + summary: Find pets with x-spring-paginated and $ref sort enum + operationId: findPetsWithRefSort + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + default: 0 + - name: size + in: query + schema: + type: integer + default: 20 + - name: sort + in: query + description: Sort order + schema: + $ref: '#/components/schemas/PetSort' + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /pet/findWithoutSortEnum: + get: + tags: + - pet + summary: Find pets with pagination but sort has no enum constraint + operationId: findPetsWithoutSortEnum + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + default: 0 + - name: size + in: query + schema: + type: integer + default: 20 + - name: sort + in: query + description: Sort order (no enum constraint) + schema: + type: string + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /pet/findNonPaginatedWithSortEnum: + get: + tags: + - pet + summary: Find pets without pagination but sort param has enum — no sort validation expected + operationId: findPetsNonPaginatedWithSortEnum + parameters: + - name: sort + in: query + description: Sort order with enum but no pagination + schema: + type: string + enum: + - "id,asc" + - "id,desc" + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' +components: + schemas: + PetSort: + type: string + enum: + - "id,asc" + - "id,desc" + - "createdAt,asc" + - "createdAt,desc" + Pet: + type: object + required: + - name + properties: + id: + type: integer + format: int64 + name: + type: string + status: + type: string + description: pet status in the store From e3bb3ad94a3f510cfbc68ff1800847717c6c13b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1chym=20Metli=C4=8Dka?= Date: Sat, 11 Apr 2026 00:50:25 +0200 Subject: [PATCH 02/15] add sample --- .../kotlin-spring-boot-sort-validation.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 bin/configs/kotlin-spring-boot-sort-validation.yaml diff --git a/bin/configs/kotlin-spring-boot-sort-validation.yaml b/bin/configs/kotlin-spring-boot-sort-validation.yaml new file mode 100644 index 000000000000..22973c2f36e2 --- /dev/null +++ b/bin/configs/kotlin-spring-boot-sort-validation.yaml @@ -0,0 +1,16 @@ +generatorName: kotlin-spring +outputDir: samples/server/petstore/kotlin-springboot-sort-validation +library: spring-boot +inputSpec: modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml +templateDir: modules/openapi-generator/src/main/resources/kotlin-spring +additionalProperties: + documentationProvider: none + annotationLibrary: none + useSwaggerUI: "false" + serviceImplementation: "true" + serializableModel: "true" + beanValidations: "true" + useSpringBoot3: "true" + generateSortValidation: "true" + useTags: "true" + requestMappingMode: api_interface From 8ebb3cfe710fc801b00d19b1f6f6d6c3db773d28 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Sat, 11 Apr 2026 00:59:44 +0200 Subject: [PATCH 03/15] add generated samples --- .../README.md | 2 +- .../org/openapitools/server/models/Cat.kt | 13 +- .../org/openapitools/server/models/Dog.kt | 21 +- .../org/openapitools/server/models/Pet.kt | 23 +- .../.openapi-generator-ignore | 23 ++ .../.openapi-generator/FILES | 21 ++ .../.openapi-generator/VERSION | 1 + .../README.md | 21 ++ .../build.gradle.kts | 43 +++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43453 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + .../kotlin-springboot-sort-validation/gradlew | 249 ++++++++++++++++++ .../gradlew.bat | 92 +++++++ .../kotlin-springboot-sort-validation/pom.xml | 148 +++++++++++ .../settings.gradle | 15 ++ .../kotlin/org/openapitools/Application.kt | 13 + .../kotlin/org/openapitools/api/ApiUtil.kt | 19 ++ .../kotlin/org/openapitools/api/Exceptions.kt | 30 +++ .../org/openapitools/api/PetApiController.kt | 108 ++++++++ .../org/openapitools/api/PetApiService.kt | 55 ++++ .../org/openapitools/api/PetApiServiceImpl.kt | 30 +++ .../EnumConverterConfiguration.kt | 26 ++ .../openapitools/configuration/ValidSort.kt | 76 ++++++ .../main/kotlin/org/openapitools/model/Pet.kt | 34 +++ .../kotlin/org/openapitools/model/PetSort.kt | 37 +++ .../src/main/resources/application.yaml | 10 + .../kotlin/org/openapitools/api/PetApiTest.kt | 95 +++++++ 27 files changed, 1191 insertions(+), 21 deletions(-) create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator-ignore create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/VERSION create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/README.md create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/build.gradle.kts create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/gradle/wrapper/gradle-wrapper.jar create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/gradle/wrapper/gradle-wrapper.properties create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/gradlew create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/gradlew.bat create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/pom.xml create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/settings.gradle create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/Application.kt create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/ApiUtil.kt create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/Exceptions.kt create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiController.kt create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiService.kt create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/EnumConverterConfiguration.kt create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidSort.kt create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/model/Pet.kt create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/model/PetSort.kt create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/src/main/resources/application.yaml create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/src/test/kotlin/org/openapitools/api/PetApiTest.kt diff --git a/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/README.md b/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/README.md index 3f8be808a94f..793a5bf73d45 100644 --- a/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/README.md +++ b/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/README.md @@ -1,4 +1,4 @@ -# org.openapitools.server - Kotlin Server library for Basic polymorphism example with discriminator +# org.openapitools.server - Kotlin Server library for Polymorphism example with allOf and discriminator No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) diff --git a/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Cat.kt b/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Cat.kt index dd82367937fe..e9e835517bae 100644 --- a/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Cat.kt +++ b/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Cat.kt @@ -1,5 +1,5 @@ /** - * Basic polymorphism example with discriminator + * Polymorphism example with allOf and discriminator * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) * * The version of the OpenAPI document: 1.0 @@ -11,11 +11,11 @@ */ package org.openapitools.server.models +import org.openapitools.server.models.Pet /** - * A pet cat + * A representation of a cat * @param huntingSkill The measured skill for hunting - * @param petType */ data class Cat( /* The measured skill for hunting */ @@ -23,9 +23,12 @@ data class Cat( @field:com.fasterxml.jackson.annotation.JsonProperty("huntingSkill") val huntingSkill: Cat.HuntingSkill, + @field:com.fasterxml.jackson.annotation.JsonProperty("name") + override val name: kotlin.String, + @field:com.fasterxml.jackson.annotation.JsonProperty("petType") - val petType: kotlin.Any? = null -) : Pet() + override val petType: kotlin.String +) : Pet(name = name, petType = petType) { /** * The measured skill for hunting diff --git a/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Dog.kt b/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Dog.kt index 1360130bed0a..4066cb7a51f8 100644 --- a/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Dog.kt +++ b/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Dog.kt @@ -1,5 +1,5 @@ /** - * Basic polymorphism example with discriminator + * Polymorphism example with allOf and discriminator * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) * * The version of the OpenAPI document: 1.0 @@ -11,19 +11,24 @@ */ package org.openapitools.server.models +import org.openapitools.server.models.Pet /** - * A pet dog - * @param petType + * A representation of a dog * @param packSize the size of the pack the dog is from */ data class Dog( - - @field:com.fasterxml.jackson.annotation.JsonProperty("petType") - val petType: kotlin.Any?, /* the size of the pack the dog is from */ @field:com.fasterxml.jackson.annotation.JsonProperty("packSize") - val packSize: kotlin.Int = 0 -) : Pet() + val packSize: kotlin.Int = 0, + + @field:com.fasterxml.jackson.annotation.JsonProperty("name") + override val name: kotlin.String, + + @field:com.fasterxml.jackson.annotation.JsonProperty("petType") + override val petType: kotlin.String +) : Pet(name = name, petType = petType) +{ +} diff --git a/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Pet.kt b/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Pet.kt index b2c3ac1d7be4..5812ac1944eb 100644 --- a/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Pet.kt +++ b/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Pet.kt @@ -1,5 +1,5 @@ /** - * Basic polymorphism example with discriminator + * Polymorphism example with allOf and discriminator * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) * * The version of the OpenAPI document: 1.0 @@ -11,16 +11,25 @@ */ package org.openapitools.server.models -import org.openapitools.server.models.Cat -import org.openapitools.server.models.Dog /** * + * @param name + * @param petType */ -@com.fasterxml.jackson.annotation.JsonTypeInfo(use = com.fasterxml.jackson.annotation.JsonTypeInfo.Id.NAME, include = com.fasterxml.jackson.annotation.JsonTypeInfo.As.PROPERTY, property = "petType", visible = false) +@com.fasterxml.jackson.annotation.JsonTypeInfo(use = com.fasterxml.jackson.annotation.JsonTypeInfo.Id.NAME, include = com.fasterxml.jackson.annotation.JsonTypeInfo.As.PROPERTY, property = "petType", visible = true) @com.fasterxml.jackson.annotation.JsonSubTypes( - com.fasterxml.jackson.annotation.JsonSubTypes.Type(value = Cat::class, name = "cat"), - com.fasterxml.jackson.annotation.JsonSubTypes.Type(value = Dog::class, name = "dog") + com.fasterxml.jackson.annotation.JsonSubTypes.Type(value = Cat::class, name = "Cat"), + com.fasterxml.jackson.annotation.JsonSubTypes.Type(value = Dog::class, name = "Dog") +) +sealed class Pet( + + @field:com.fasterxml.jackson.annotation.JsonProperty("name") + open val name: kotlin.String +, + + @field:com.fasterxml.jackson.annotation.JsonProperty("petType") + open val petType: kotlin.String + ) -sealed class Pet diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator-ignore b/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator-ignore new file mode 100644 index 000000000000..7484ee590a38 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator-ignore @@ -0,0 +1,23 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES b/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES new file mode 100644 index 000000000000..bfa7d45803bd --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES @@ -0,0 +1,21 @@ +.openapi-generator-ignore +README.md +build.gradle.kts +gradle/wrapper/gradle-wrapper.jar +gradle/wrapper/gradle-wrapper.properties +gradlew +gradlew.bat +pom.xml +settings.gradle +src/main/kotlin/org/openapitools/Application.kt +src/main/kotlin/org/openapitools/api/ApiUtil.kt +src/main/kotlin/org/openapitools/api/Exceptions.kt +src/main/kotlin/org/openapitools/api/PetApiController.kt +src/main/kotlin/org/openapitools/api/PetApiService.kt +src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt +src/main/kotlin/org/openapitools/configuration/EnumConverterConfiguration.kt +src/main/kotlin/org/openapitools/configuration/ValidSort.kt +src/main/kotlin/org/openapitools/model/Pet.kt +src/main/kotlin/org/openapitools/model/PetSort.kt +src/main/resources/application.yaml +src/test/kotlin/org/openapitools/api/PetApiTest.kt diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/VERSION b/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/VERSION new file mode 100644 index 000000000000..f7962df3e243 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.22.0-SNAPSHOT diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/README.md b/samples/server/petstore/kotlin-springboot-sort-validation/README.md new file mode 100644 index 000000000000..3808563e513f --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/README.md @@ -0,0 +1,21 @@ +# openAPIPetstoreSortValidationTest + +This Kotlin based [Spring Boot](https://spring.io/projects/spring-boot) application has been generated using the [OpenAPI Generator](https://github.com/OpenAPITools/openapi-generator). + +## Getting Started + +This document assumes you have either maven or gradle available, either via the wrapper or otherwise. This does not come with a gradle / maven wrapper checked in. + +By default a [`pom.xml`](pom.xml) file will be generated. If you specified `gradleBuildFile=true` when generating this project, a `build.gradle.kts` will also be generated. Note this uses [Gradle Kotlin DSL](https://github.com/gradle/kotlin-dsl). + +To build the project using maven, run: +```bash +mvn package && java -jar target/openapi-spring-1.0.0.jar +``` + +To build the project using gradle, run: +```bash +gradle build && java -jar build/libs/openapi-spring-1.0.0.jar +``` + +If all builds successfully, the server should run on [http://localhost:8080/](http://localhost:8080/) diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/build.gradle.kts b/samples/server/petstore/kotlin-springboot-sort-validation/build.gradle.kts new file mode 100644 index 000000000000..db73c5e21693 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/build.gradle.kts @@ -0,0 +1,43 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +group = "org.openapitools" +version = "1.0.0" +java.sourceCompatibility = JavaVersion.VERSION_17 + +repositories { + mavenCentral() + maven { url = uri("https://repo.spring.io/milestone") } +} + +tasks.withType { + kotlinOptions.jvmTarget = "17" +} + +plugins { + val kotlinVersion = "1.9.25" + id("org.jetbrains.kotlin.jvm") version kotlinVersion + id("org.jetbrains.kotlin.plugin.jpa") version kotlinVersion + id("org.jetbrains.kotlin.plugin.spring") version kotlinVersion + id("org.springframework.boot") version "3.0.2" + id("io.spring.dependency-management") version "1.0.14.RELEASE" +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.springframework.boot:spring-boot-starter-web") + + implementation("com.google.code.findbugs:jsr305:3.0.2") + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml") + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.springframework.data:spring-data-commons") + implementation("jakarta.validation:jakarta.validation-api") + implementation("jakarta.annotation:jakarta.annotation-api:2.1.0") + + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.boot:spring-boot-starter-test") { + exclude(module = "junit") + } +} diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/gradle/wrapper/gradle-wrapper.jar b/samples/server/petstore/kotlin-springboot-sort-validation/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e6441136f3d4ba8a0da8d277868979cfbc8ad796 GIT binary patch literal 43453 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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
NnX)EH+Nua)3Y(c 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 '} +case $link in #( +/*) app_path=$link ;; #( +*) app_path=$APP_HOME$link ;; +esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { +echo "$*" +} >&2 + +die () { +echo +echo "$*" +echo +exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( +CYGWIN* ) cygwin=true ;; #( +Darwin* ) darwin=true ;; #( +MSYS* | MINGW* ) msys=true ;; #( +NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then +if [ -x "$JAVA_HOME/jre/sh/java" ] ; then +# IBM's JDK on AIX uses strange locations for the executables +JAVACMD=$JAVA_HOME/jre/sh/java +else +JAVACMD=$JAVA_HOME/bin/java +fi +if [ ! -x "$JAVACMD" ] ; then +die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi +else +JAVACMD=java +if ! command -v java >/dev/null 2>&1 +then +die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then +case $MAX_FD in #( +max*) +# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. +# shellcheck disable=SC2039,SC3045 +MAX_FD=$( ulimit -H -n ) || +warn "Could not query maximum file descriptor limit" +esac +case $MAX_FD in #( +'' | soft) :;; #( +*) +# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. +# shellcheck disable=SC2039,SC3045 +ulimit -n "$MAX_FD" || +warn "Could not set maximum file descriptor limit to $MAX_FD" +esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then +APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) +CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + +JAVACMD=$( cygpath --unix "$JAVACMD" ) + +# Now convert the arguments - kludge to limit ourselves to /bin/sh +for arg do +if +case $arg in #( +-*) false ;; # don't mess with options #( +/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath +[ -e "$t" ] ;; #( +*) false ;; +esac +then +arg=$( cygpath --path --ignore --mixed "$arg" ) +fi +# Roll the args list around exactly as many times as the number of +# args, so each arg winds up back in the position where it started, but +# possibly modified. +# +# NB: a `for` loop captures its iteration list before it begins, so +# changing the positional parameters here affects neither the number of +# iterations, nor the values presented in `arg`. +shift # remove old arg +set -- "$@" "$arg" # push replacement arg +done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ +"-Dorg.gradle.appname=$APP_BASE_NAME" \ +-classpath "$CLASSPATH" \ +org.gradle.wrapper.GradleWrapperMain \ +"$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then +die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( +printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | +xargs -n1 | +sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | +tr '\n' ' ' +)" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/gradlew.bat b/samples/server/petstore/kotlin-springboot-sort-validation/gradlew.bat new file mode 100644 index 000000000000..25da30dbdeee --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/pom.xml b/samples/server/petstore/kotlin-springboot-sort-validation/pom.xml new file mode 100644 index 000000000000..3844d9e01f44 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/pom.xml @@ -0,0 +1,148 @@ + + 4.0.0 + org.openapitools + openapi-spring + jar + openapi-spring + 1.0.0 + + 3.0.2 + 2.1.0 + 1.7.10 + + 1.7.10 + UTF-8 + + + org.springframework.boot + spring-boot-starter-parent + 3.1.3 + + + + repository.spring.milestone + Spring Milestone Repository + https://repo.spring.io/milestone + + + + + spring-milestones + https://repo.spring.io/milestone + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/test/kotlin + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + + + + + kotlin-maven-plugin + org.jetbrains.kotlin + ${kotlin.version} + + + spring + + 17 + + + + compile + compile + + compile + + + + test-compile + test-compile + + test-compile + + + + + + org.jetbrains.kotlin + kotlin-maven-allopen + ${kotlin.version} + + + + + + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.data + spring-data-commons + + + + + + + com.google.code.findbugs + jsr305 + ${findbugs-jsr305.version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.fasterxml.jackson.module + jackson-module-kotlin + + + + jakarta.validation + jakarta.validation-api + + + jakarta.annotation + jakarta.annotation-api + ${jakarta-annotation.version} + provided + + + org.jetbrains.kotlin + kotlin-test-junit5 + ${kotlin-test-junit5.version} + test + + + diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/settings.gradle b/samples/server/petstore/kotlin-springboot-sort-validation/settings.gradle new file mode 100644 index 000000000000..14844905cd40 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/settings.gradle @@ -0,0 +1,15 @@ +pluginManagement { + repositories { + maven { url = uri("https://repo.spring.io/snapshot") } + maven { url = uri("https://repo.spring.io/milestone") } + gradlePluginPortal() + } + resolutionStrategy { + eachPlugin { + if (requested.id.id == "org.springframework.boot") { + useModule("org.springframework.boot:spring-boot-gradle-plugin:${requested.version}") + } + } + } +} +rootProject.name = "openapi-spring" diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/Application.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/Application.kt new file mode 100644 index 000000000000..2fe6de62479e --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/Application.kt @@ -0,0 +1,13 @@ +package org.openapitools + +import org.springframework.boot.runApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.context.annotation.ComponentScan + +@SpringBootApplication +@ComponentScan(basePackages = ["org.openapitools", "org.openapitools.api", "org.openapitools.model"]) +class Application + +fun main(args: Array) { + runApplication(*args) +} diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/ApiUtil.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/ApiUtil.kt new file mode 100644 index 000000000000..03344e13b474 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/ApiUtil.kt @@ -0,0 +1,19 @@ +package org.openapitools.api + +import org.springframework.web.context.request.NativeWebRequest + +import jakarta.servlet.http.HttpServletResponse +import java.io.IOException + +object ApiUtil { + fun setExampleResponse(req: NativeWebRequest, contentType: String, example: String) { + try { + val res = req.getNativeResponse(HttpServletResponse::class.java) + res?.characterEncoding = "UTF-8" + res?.addHeader("Content-Type", contentType) + res?.writer?.print(example) + } catch (e: IOException) { + throw RuntimeException(e) + } + } +} diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/Exceptions.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/Exceptions.kt new file mode 100644 index 000000000000..1bd78f54576a --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/Exceptions.kt @@ -0,0 +1,30 @@ +package org.openapitools.api + +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import jakarta.servlet.http.HttpServletResponse +import jakarta.validation.ConstraintViolationException + +// TODO Extend ApiException for custom exception handling, e.g. the below NotFound exception +sealed class ApiException(msg: String, val code: Int) : Exception(msg) + +class NotFoundException(msg: String, code: Int = HttpStatus.NOT_FOUND.value()) : ApiException(msg, code) + +@Configuration("org.openapitools.api.DefaultExceptionHandler") +@ControllerAdvice +class DefaultExceptionHandler { + + @ExceptionHandler(value = [ApiException::class]) + fun onApiException(ex: ApiException, response: HttpServletResponse): Unit = + response.sendError(ex.code, ex.message) + + @ExceptionHandler(value = [NotImplementedError::class]) + fun onNotImplemented(ex: NotImplementedError, response: HttpServletResponse): Unit = + response.sendError(HttpStatus.NOT_IMPLEMENTED.value()) + + @ExceptionHandler(value = [ConstraintViolationException::class]) + fun onConstraintViolation(ex: ConstraintViolationException, response: HttpServletResponse): Unit = + response.sendError(HttpStatus.BAD_REQUEST.value(), ex.constraintViolations.joinToString(", ") { it.message }) +} diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiController.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiController.kt new file mode 100644 index 000000000000..3991d64cd2bb --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiController.kt @@ -0,0 +1,108 @@ +package org.openapitools.api + +import org.springframework.data.domain.Pageable +import org.openapitools.model.Pet +import org.openapitools.model.PetSort +import org.openapitools.configuration.ValidSort +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity + +import org.springframework.web.bind.annotation.* +import org.springframework.validation.annotation.Validated +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.beans.factory.annotation.Autowired + +import jakarta.validation.Valid +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size + +import kotlin.collections.List +import kotlin.collections.Map + +@RestController +@Validated +class PetApiController(@Autowired(required = true) val service: PetApiService) { + + + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findAutoDetectedWithSort" + value = [PATH_FIND_PETS_AUTO_DETECTED_WITH_SORT], + produces = ["application/json"] + ) + fun findPetsAutoDetectedWithSort( + @Valid @RequestParam(value = "status", required = false) status: kotlin.String?, + @Valid @RequestParam(value = "page", required = false, defaultValue = "0") page: kotlin.Int, + @Valid @RequestParam(value = "size", required = false, defaultValue = "20") size: kotlin.Int, + @Valid @RequestParam(value = "sort", required = false) sort: kotlin.String? + ): ResponseEntity> { + return ResponseEntity(service.findPetsAutoDetectedWithSort(status, page, size, sort), HttpStatus.valueOf(200)) + } + + + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findNonPaginatedWithSortEnum" + value = [PATH_FIND_PETS_NON_PAGINATED_WITH_SORT_ENUM], + produces = ["application/json"] + ) + fun findPetsNonPaginatedWithSortEnum( + @Valid @RequestParam(value = "sort", required = false) sort: kotlin.String? + ): ResponseEntity> { + return ResponseEntity(service.findPetsNonPaginatedWithSortEnum(sort), HttpStatus.valueOf(200)) + } + + + @ValidSort(allowedValues = ["id,asc", "id,desc", "createdAt,asc", "createdAt,desc"]) + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithRefSort" + value = [PATH_FIND_PETS_WITH_REF_SORT], + produces = ["application/json"] + ) + fun findPetsWithRefSort(pageable: Pageable): ResponseEntity> { + return ResponseEntity(service.findPetsWithRefSort(), HttpStatus.valueOf(200)) + } + + + @ValidSort(allowedValues = ["id,asc", "id,desc", "name,asc", "name,desc"]) + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findByStatusWithSort" + value = [PATH_FIND_PETS_WITH_SORT_ENUM], + produces = ["application/json"] + ) + fun findPetsWithSortEnum( + @Valid @RequestParam(value = "status", required = false) status: kotlin.String?, + pageable: Pageable + ): ResponseEntity> { + return ResponseEntity(service.findPetsWithSortEnum(status), HttpStatus.valueOf(200)) + } + + + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithoutSortEnum" + value = [PATH_FIND_PETS_WITHOUT_SORT_ENUM], + produces = ["application/json"] + ) + fun findPetsWithoutSortEnum(pageable: Pageable): ResponseEntity> { + return ResponseEntity(service.findPetsWithoutSortEnum(), HttpStatus.valueOf(200)) + } + + companion object { + //for your own safety never directly reuse these path definitions in tests + const val PATH_FIND_PETS_AUTO_DETECTED_WITH_SORT: String = "/pet/findAutoDetectedWithSort" + const val PATH_FIND_PETS_NON_PAGINATED_WITH_SORT_ENUM: String = "/pet/findNonPaginatedWithSortEnum" + const val PATH_FIND_PETS_WITH_REF_SORT: String = "/pet/findWithRefSort" + const val PATH_FIND_PETS_WITH_SORT_ENUM: String = "/pet/findByStatusWithSort" + const val PATH_FIND_PETS_WITHOUT_SORT_ENUM: String = "/pet/findWithoutSortEnum" + } +} diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiService.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiService.kt new file mode 100644 index 000000000000..19bac55816f4 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiService.kt @@ -0,0 +1,55 @@ +package org.openapitools.api + +import org.springframework.data.domain.Pageable +import org.openapitools.model.Pet +import org.openapitools.model.PetSort +import org.openapitools.configuration.ValidSort + +interface PetApiService { + + /** + * GET /pet/findAutoDetectedWithSort : Find pets with auto-detected pagination and sort enum + * + * @param status Status filter (optional) + * @param page (optional, default to 0) + * @param size (optional, default to 20) + * @param sort Sort order (optional) + * @return successful operation (status code 200) + * @see PetApi#findPetsAutoDetectedWithSort + */ + fun findPetsAutoDetectedWithSort(status: kotlin.String?, page: kotlin.Int, size: kotlin.Int, sort: kotlin.String?): List + + /** + * GET /pet/findNonPaginatedWithSortEnum : Find pets without pagination but sort param has enum — no sort validation expected + * + * @param sort Sort order with enum but no pagination (optional) + * @return successful operation (status code 200) + * @see PetApi#findPetsNonPaginatedWithSortEnum + */ + fun findPetsNonPaginatedWithSortEnum(sort: kotlin.String?): List + + /** + * GET /pet/findWithRefSort : Find pets with x-spring-paginated and $ref sort enum + * + * @return successful operation (status code 200) + * @see PetApi#findPetsWithRefSort + */ + fun findPetsWithRefSort(): List + + /** + * GET /pet/findByStatusWithSort : Find pets with explicit x-spring-paginated and inline sort enum + * + * @param status Status filter (optional) + * @return successful operation (status code 200) + * @see PetApi#findPetsWithSortEnum + */ + fun findPetsWithSortEnum(status: kotlin.String?): List + + /** + * GET /pet/findWithoutSortEnum : Find pets with pagination but sort has no enum constraint + * + * @return successful operation (status code 200) + * @see PetApi#findPetsWithoutSortEnum + */ + fun findPetsWithoutSortEnum(): List +} diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt new file mode 100644 index 000000000000..3e0220ea2166 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt @@ -0,0 +1,30 @@ +package org.openapitools.api + +import org.springframework.data.domain.Pageable +import org.openapitools.model.Pet +import org.openapitools.model.PetSort +import org.openapitools.configuration.ValidSort +import org.springframework.stereotype.Service +@Service +class PetApiServiceImpl : PetApiService { + + override fun findPetsAutoDetectedWithSort(status: kotlin.String?, page: kotlin.Int, size: kotlin.Int, sort: kotlin.String?): List { + TODO("Implement me") + } + + override fun findPetsNonPaginatedWithSortEnum(sort: kotlin.String?): List { + TODO("Implement me") + } + + override fun findPetsWithRefSort(): List { + TODO("Implement me") + } + + override fun findPetsWithSortEnum(status: kotlin.String?): List { + TODO("Implement me") + } + + override fun findPetsWithoutSortEnum(): List { + TODO("Implement me") + } +} diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/EnumConverterConfiguration.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/EnumConverterConfiguration.kt new file mode 100644 index 000000000000..47c86e5540bf --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/EnumConverterConfiguration.kt @@ -0,0 +1,26 @@ +package org.openapitools.configuration + +import org.openapitools.model.PetSort + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.convert.converter.Converter + +/** + * This class provides Spring Converter beans for the enum models in the OpenAPI specification. + * + * By default, Spring only converts primitive types to enums using Enum::valueOf, which can prevent + * correct conversion if the OpenAPI specification is using an `enumPropertyNaming` other than + * `original` or the specification has an integer enum. + */ +@Configuration(value = "org.openapitools.configuration.enumConverterConfiguration") +class EnumConverterConfiguration { + + @Bean(name = ["org.openapitools.configuration.EnumConverterConfiguration.petSortConverter"]) + fun petSortConverter(): Converter { + return object: Converter { + override fun convert(source: kotlin.String): PetSort = PetSort.forValue(source) + } + } + +} diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidSort.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidSort.kt new file mode 100644 index 000000000000..5282b0f1a28a --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidSort.kt @@ -0,0 +1,76 @@ +package org.openapitools.configuration + +import jakarta.validation.Constraint +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext +import jakarta.validation.Payload +import jakarta.validation.constraintvalidation.SupportedValidationTarget +import jakarta.validation.constraintvalidation.ValidationTarget +import org.springframework.data.domain.Pageable + +/** + * Validates that sort properties in a [Pageable] parameter match the allowed values. + * + * This annotation can only be applied to methods that have a [Pageable] parameter. + * The validator checks that each sort property and direction combination in the [Pageable] + * matches one of the strings specified in [allowedValues]. + * + * Expected value format: `"property,direction"` (e.g. `"id,asc"`, `"name,desc"`). + * + * @property allowedValues The allowed sort strings (e.g. `["id,asc", "id,desc"]`) + * @property groups Validation groups (optional) + * @property payload Additional payload (optional) + * @property message Validation error message (default: "Invalid sort column") + */ +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +@Constraint(validatedBy = [SortValidator::class]) +@Target(AnnotationTarget.FUNCTION) +annotation class ValidSort( + val allowedValues: Array, + val groups: Array> = [], + val payload: Array> = [], + val message: String = "Invalid sort column" +) + +@SupportedValidationTarget(ValidationTarget.PARAMETERS) +class SortValidator : ConstraintValidator> { + + private lateinit var allowedValues: Set + + override fun initialize(constraintAnnotation: ValidSort) { + allowedValues = constraintAnnotation.allowedValues.toSet() + } + + override fun isValid(parameters: Array?, context: ConstraintValidatorContext): Boolean { + val pageable = parameters?.filterIsInstance()?.firstOrNull() + ?: throw IllegalStateException( + "@ValidSort can only be used on methods with a Pageable parameter. " + + "Ensure the annotated method has a parameter of type org.springframework.data.domain.Pageable." + ) + + val invalid = pageable.sort + .foldIndexed(emptyMap()) { index, acc, order -> + val sortValue = "${order.property},${order.direction.name.lowercase()}" + if (sortValue !in allowedValues) acc + (index to order.property) + else acc + } + .toSortedMap() + + if (invalid.isNotEmpty()) { + context.disableDefaultConstraintViolation() + invalid.forEach { (index, property) -> + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate} [$property]" + ) + .addPropertyNode("sort") + .addPropertyNode("property") + .inIterable() + .atIndex(index) + .addConstraintViolation() + } + } + + return invalid.isEmpty() + } +} diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/model/Pet.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/model/Pet.kt new file mode 100644 index 000000000000..5e896ae0469b --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/model/Pet.kt @@ -0,0 +1,34 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +/** + * + * @param name + * @param id + * @param status pet status in the store + */ +data class Pet( + + @get:JsonProperty("name", required = true) val name: kotlin.String, + + @get:JsonProperty("id") val id: kotlin.Long? = null, + + @get:JsonProperty("status") val status: kotlin.String? = null +) : java.io.Serializable { + + companion object { + private const val serialVersionUID: kotlin.Long = 1 + } +} + diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/model/PetSort.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/model/PetSort.kt new file mode 100644 index 000000000000..06ce004b5a90 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/model/PetSort.kt @@ -0,0 +1,37 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonValue +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +/** +* +* Values: idCommaAsc,idCommaDesc,createdAtCommaAsc,createdAtCommaDesc +*/ +enum class PetSort(@get:JsonValue val value: kotlin.String) : java.io.Serializable { + + idCommaAsc("id,asc"), + idCommaDesc("id,desc"), + createdAtCommaAsc("createdAt,asc"), + createdAtCommaDesc("createdAt,desc"); + + companion object { + @JvmStatic + @JsonCreator + fun forValue(value: kotlin.String): PetSort { + return values().firstOrNull{it -> it.value == value} + ?: throw IllegalArgumentException("Unexpected value '$value' for enum 'PetSort'") + } + } +} + diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/resources/application.yaml b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/resources/application.yaml new file mode 100644 index 000000000000..50e223115e60 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/resources/application.yaml @@ -0,0 +1,10 @@ +spring: + application: + name: openAPIPetstoreSortValidationTest + + jackson: + serialization: + WRITE_DATES_AS_TIMESTAMPS: false + +server: + port: 8080 diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/test/kotlin/org/openapitools/api/PetApiTest.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/test/kotlin/org/openapitools/api/PetApiTest.kt new file mode 100644 index 000000000000..13173ce0a6e3 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/test/kotlin/org/openapitools/api/PetApiTest.kt @@ -0,0 +1,95 @@ +package org.openapitools.api + +import org.springframework.data.domain.Pageable +import org.openapitools.model.Pet +import org.openapitools.model.PetSort +import org.openapitools.configuration.ValidSort +import org.junit.jupiter.api.Test +import org.springframework.http.ResponseEntity + +class PetApiTest { + + private val service: PetApiService = PetApiServiceImpl() + private val api: PetApiController = PetApiController(service) + + /** + * To test PetApiController.findPetsAutoDetectedWithSort + * + * @throws ApiException + * if the Api call fails + */ + @Test + fun findPetsAutoDetectedWithSortTest() { + val status: kotlin.String? = TODO() + val page: kotlin.Int = TODO() + val size: kotlin.Int = TODO() + val sort: kotlin.String? = TODO() + + + val response: ResponseEntity> = api.findPetsAutoDetectedWithSort(status, page, size, sort) + + // TODO: test validations + } + + /** + * To test PetApiController.findPetsNonPaginatedWithSortEnum + * + * @throws ApiException + * if the Api call fails + */ + @Test + fun findPetsNonPaginatedWithSortEnumTest() { + val sort: kotlin.String? = TODO() + + + val response: ResponseEntity> = api.findPetsNonPaginatedWithSortEnum(sort) + + // TODO: test validations + } + + /** + * To test PetApiController.findPetsWithRefSort + * + * @throws ApiException + * if the Api call fails + */ + @Test + fun findPetsWithRefSortTest() { + + val pageable: Pageable = TODO() + val response: ResponseEntity> = api.findPetsWithRefSort(pageable) + + // TODO: test validations + } + + /** + * To test PetApiController.findPetsWithSortEnum + * + * @throws ApiException + * if the Api call fails + */ + @Test + fun findPetsWithSortEnumTest() { + val status: kotlin.String? = TODO() + + val pageable: Pageable = TODO() + val response: ResponseEntity> = api.findPetsWithSortEnum(status, pageable) + + // TODO: test validations + } + + /** + * To test PetApiController.findPetsWithoutSortEnum + * + * @throws ApiException + * if the Api call fails + */ + @Test + fun findPetsWithoutSortEnumTest() { + + val pageable: Pageable = TODO() + val response: ResponseEntity> = api.findPetsWithoutSortEnum(pageable) + + // TODO: test validations + } +} From c751e31fd73b7d6869a34ea283e77ab4a5bed0de Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Sat, 11 Apr 2026 01:19:08 +0200 Subject: [PATCH 04/15] add to compiled samples --- .github/workflows/samples-kotlin-server-jdk17.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/samples-kotlin-server-jdk17.yaml b/.github/workflows/samples-kotlin-server-jdk17.yaml index b5bde97258c5..d7d64fd88957 100644 --- a/.github/workflows/samples-kotlin-server-jdk17.yaml +++ b/.github/workflows/samples-kotlin-server-jdk17.yaml @@ -11,6 +11,7 @@ on: - 'samples/server/petstore/kotlin-server-required-and-nullable-properties/**' - 'samples/server/petstore/kotlin-spring-declarative*/**' - 'samples/server/petstore/kotlin-spring-sealed-interfaces/**' + - 'samples/server/petstore/kotlin-springboot-sort-validation/**' # comment out due to gradle build failure # - samples/server/petstore/kotlin-spring-default/** pull_request: @@ -23,6 +24,7 @@ on: - 'samples/server/petstore/kotlin-server-required-and-nullable-properties/**' - 'samples/server/petstore/kotlin-spring-declarative*/**' - 'samples/server/petstore/kotlin-spring-sealed-interfaces/**' + - 'samples/server/petstore/kotlin-springboot-sort-validation/**' # comment out due to gradle build failure # - samples/server/petstore/kotlin-spring-default/** @@ -62,6 +64,7 @@ jobs: - samples/server/petstore/kotlin-spring-declarative-interface-reactive-reactor-wrapped - samples/server/petstore/kotlin-spring-declarative-interface-wrapped - samples/server/petstore/kotlin-spring-sealed-interfaces + - samples/server/petstore/kotlin-springboot-sort-validation # comment out due to gradle build failure # - samples/server/petstore/kotlin-spring-default/ steps: From 58fe32badf75cd5be7961b4b99b75ef912b34b66 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Sat, 11 Apr 2026 01:50:10 +0200 Subject: [PATCH 05/15] add to compiled samples --- .../README.md | 2 +- .../org/openapitools/server/models/Cat.kt | 13 ++++------- .../org/openapitools/server/models/Dog.kt | 21 +++++++---------- .../org/openapitools/server/models/Pet.kt | 23 ++++++------------- .../.openapi-generator/FILES | 2 -- 5 files changed, 21 insertions(+), 40 deletions(-) diff --git a/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/README.md b/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/README.md index 793a5bf73d45..3f8be808a94f 100644 --- a/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/README.md +++ b/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/README.md @@ -1,4 +1,4 @@ -# org.openapitools.server - Kotlin Server library for Polymorphism example with allOf and discriminator +# org.openapitools.server - Kotlin Server library for Basic polymorphism example with discriminator No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) diff --git a/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Cat.kt b/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Cat.kt index e9e835517bae..dd82367937fe 100644 --- a/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Cat.kt +++ b/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Cat.kt @@ -1,5 +1,5 @@ /** - * Polymorphism example with allOf and discriminator + * Basic polymorphism example with discriminator * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) * * The version of the OpenAPI document: 1.0 @@ -11,11 +11,11 @@ */ package org.openapitools.server.models -import org.openapitools.server.models.Pet /** - * A representation of a cat + * A pet cat * @param huntingSkill The measured skill for hunting + * @param petType */ data class Cat( /* The measured skill for hunting */ @@ -23,12 +23,9 @@ data class Cat( @field:com.fasterxml.jackson.annotation.JsonProperty("huntingSkill") val huntingSkill: Cat.HuntingSkill, - @field:com.fasterxml.jackson.annotation.JsonProperty("name") - override val name: kotlin.String, - @field:com.fasterxml.jackson.annotation.JsonProperty("petType") - override val petType: kotlin.String -) : Pet(name = name, petType = petType) + val petType: kotlin.Any? = null +) : Pet() { /** * The measured skill for hunting diff --git a/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Dog.kt b/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Dog.kt index 4066cb7a51f8..1360130bed0a 100644 --- a/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Dog.kt +++ b/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Dog.kt @@ -1,5 +1,5 @@ /** - * Polymorphism example with allOf and discriminator + * Basic polymorphism example with discriminator * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) * * The version of the OpenAPI document: 1.0 @@ -11,24 +11,19 @@ */ package org.openapitools.server.models -import org.openapitools.server.models.Pet /** - * A representation of a dog + * A pet dog + * @param petType * @param packSize the size of the pack the dog is from */ data class Dog( + + @field:com.fasterxml.jackson.annotation.JsonProperty("petType") + val petType: kotlin.Any?, /* the size of the pack the dog is from */ @field:com.fasterxml.jackson.annotation.JsonProperty("packSize") - val packSize: kotlin.Int = 0, - - @field:com.fasterxml.jackson.annotation.JsonProperty("name") - override val name: kotlin.String, - - @field:com.fasterxml.jackson.annotation.JsonProperty("petType") - override val petType: kotlin.String -) : Pet(name = name, petType = petType) -{ -} + val packSize: kotlin.Int = 0 +) : Pet() diff --git a/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Pet.kt b/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Pet.kt index 5812ac1944eb..b2c3ac1d7be4 100644 --- a/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Pet.kt +++ b/samples/server/others/kotlin-server/polymorphism-and-discriminator-disabled-jackson-fix/src/main/kotlin/org/openapitools/server/models/Pet.kt @@ -1,5 +1,5 @@ /** - * Polymorphism example with allOf and discriminator + * Basic polymorphism example with discriminator * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) * * The version of the OpenAPI document: 1.0 @@ -11,25 +11,16 @@ */ package org.openapitools.server.models +import org.openapitools.server.models.Cat +import org.openapitools.server.models.Dog /** * - * @param name - * @param petType */ -@com.fasterxml.jackson.annotation.JsonTypeInfo(use = com.fasterxml.jackson.annotation.JsonTypeInfo.Id.NAME, include = com.fasterxml.jackson.annotation.JsonTypeInfo.As.PROPERTY, property = "petType", visible = true) +@com.fasterxml.jackson.annotation.JsonTypeInfo(use = com.fasterxml.jackson.annotation.JsonTypeInfo.Id.NAME, include = com.fasterxml.jackson.annotation.JsonTypeInfo.As.PROPERTY, property = "petType", visible = false) @com.fasterxml.jackson.annotation.JsonSubTypes( - com.fasterxml.jackson.annotation.JsonSubTypes.Type(value = Cat::class, name = "Cat"), - com.fasterxml.jackson.annotation.JsonSubTypes.Type(value = Dog::class, name = "Dog") -) -sealed class Pet( - - @field:com.fasterxml.jackson.annotation.JsonProperty("name") - open val name: kotlin.String -, - - @field:com.fasterxml.jackson.annotation.JsonProperty("petType") - open val petType: kotlin.String - + com.fasterxml.jackson.annotation.JsonSubTypes.Type(value = Cat::class, name = "cat"), + com.fasterxml.jackson.annotation.JsonSubTypes.Type(value = Dog::class, name = "dog") ) +sealed class Pet diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES b/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES index bfa7d45803bd..39125d70752e 100644 --- a/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES +++ b/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES @@ -1,4 +1,3 @@ -.openapi-generator-ignore README.md build.gradle.kts gradle/wrapper/gradle-wrapper.jar @@ -18,4 +17,3 @@ src/main/kotlin/org/openapitools/configuration/ValidSort.kt src/main/kotlin/org/openapitools/model/Pet.kt src/main/kotlin/org/openapitools/model/PetSort.kt src/main/resources/application.yaml -src/test/kotlin/org/openapitools/api/PetApiTest.kt From 033bbb12be738d2b0e8898c989d5f92d9c1c936b Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Sun, 12 Apr 2026 21:41:18 +0200 Subject: [PATCH 06/15] regenerate docs --- docs/generators/kotlin-spring.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/generators/kotlin-spring.md b/docs/generators/kotlin-spring.md index 580d834f52c0..6d054a5d4823 100644 --- a/docs/generators/kotlin-spring.md +++ b/docs/generators/kotlin-spring.md @@ -34,6 +34,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |documentationProvider|Select the OpenAPI documentation provider.|
**none**
Do not publish an OpenAPI specification.
**source**
Publish the original input OpenAPI specification.
**springdoc**
Generate an OpenAPI 3 specification using SpringDoc.
|springdoc| |enumPropertyNaming|Naming convention for enum properties: 'camelCase', 'PascalCase', 'snake_case', 'UPPERCASE', and 'original'| |original| |exceptionHandler|generate default global exception handlers (not compatible with reactive. enabling reactive will disable exceptionHandler )| |true| +|generateSortValidation|Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to paginated operations whose 'sort' parameter has enum values. Requires useBeanValidation=true and library=spring-boot.| |false| |gradleBuildFile|generate a gradle build file using the Kotlin DSL| |true| |groupId|Generated artifact package's organization (i.e. maven groupId).| |org.openapitools| |implicitHeaders|Skip header parameters in the generated API methods.| |false| From 5981639da1c4e5a6a6a82d4897007ff9ec00e735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1chym=20Metli=C4=8Dka?= Date: Mon, 13 Apr 2026 15:40:52 +0200 Subject: [PATCH 07/15] feat: add pageable defaults validation for pageable operations --- .../languages/KotlinSpringServerCodegen.java | 144 +++++++++++++++ .../main/resources/kotlin-spring/api.mustache | 2 +- .../kotlin-spring/apiInterface.mustache | 2 +- .../spring/KotlinSpringServerCodegenTest.java | 105 +++++++++++ .../3_0/spring/petstore-sort-validation.yaml | 168 ++++++++++++++++++ 5 files changed, 419 insertions(+), 2 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 2477b4b6a265..87c00564dc81 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 @@ -17,6 +17,7 @@ package org.openapitools.codegen.languages; import com.google.common.collect.ImmutableMap; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.samskivert.mustache.Mustache; import com.samskivert.mustache.Mustache.Lambda; import com.samskivert.mustache.Template; @@ -188,6 +189,9 @@ public String getDescription() { // 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<>(); + public KotlinSpringServerCodegen() { super(); @@ -1071,6 +1075,33 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation codegenOperation.imports.add("ValidSort"); } + + // Generate @PageableDefault / @SortDefault.SortDefaults annotations if defaults are present + if (pageableDefaultsRegistry.containsKey(codegenOperation.operationId)) { + PageableDefaultsData defaults = pageableDefaultsRegistry.get(codegenOperation.operationId); + List pageableAnnotations = new ArrayList<>(); + + 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)); } @@ -1091,6 +1122,10 @@ public void preprocessOpenAPI(OpenAPI openAPI) { scanSortValidationEnums(openAPI); } + if (SPRING_BOOT.equals(library)) { + scanPageableDefaults(openAPI); + } + if (!additionalProperties.containsKey(TITLE)) { // The purpose of the title is for: // - README documentation @@ -1156,6 +1191,115 @@ public void preprocessOpenAPI(OpenAPI openAPI) { // TODO: Handle tags } + /** + * Scans the OpenAPI spec for pageable operations whose page/size/sort parameters have default values, + * builds the {@link #pageableDefaultsRegistry}, and registers required import mappings. + * Called from {@link #preprocessOpenAPI} for all spring-boot generations. + */ + private void scanPageableDefaults(OpenAPI openAPI) { + if (openAPI.getPaths() == null) { + return; + } + for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { + for (Operation operation : pathEntry.getValue().readOperations()) { + String operationId = operation.getOperationId(); + if (operationId == null || !willBePageable(operation)) { + continue; + } + if (operation.getParameters() == null) { + continue; + } + Integer pageDefault = null; + Integer sizeDefault = null; + List sortDefaults = new ArrayList<>(); + + 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.getDefault() == null) { + continue; + } + Object defaultValue = schema.getDefault(); + switch (param.getName()) { + case "page": + if (defaultValue instanceof Number) { + pageDefault = ((Number) defaultValue).intValue(); + } + break; + case "size": + if (defaultValue instanceof Number) { + sizeDefault = ((Number) defaultValue).intValue(); + } + break; + case "sort": + List sortValues = new ArrayList<>(); + if (defaultValue instanceof String) { + sortValues.add((String) defaultValue); + } else if (defaultValue instanceof ArrayNode) { + ((ArrayNode) defaultValue).forEach(node -> sortValues.add(node.asText())); + } else if (defaultValue instanceof List) { + for (Object item : (List) defaultValue) { + sortValues.add(item.toString()); + } + } + for (String sortStr : sortValues) { + String[] parts = sortStr.split(",", 2); + String field = parts[0].trim(); + String direction = parts.length > 1 ? parts[1].trim().toUpperCase(Locale.ROOT) : "ASC"; + sortDefaults.add(new SortFieldDefault(field, direction)); + } + break; + default: + break; + } + } + + PageableDefaultsData data = new PageableDefaultsData(pageDefault, sizeDefault, sortDefaults); + if (data.hasAny()) { + pageableDefaultsRegistry.put(operationId, data); + } + } + } + if (!pageableDefaultsRegistry.isEmpty()) { + importMapping.putIfAbsent("PageableDefault", "org.springframework.data.web.PageableDefault"); + importMapping.putIfAbsent("SortDefault", "org.springframework.data.web.SortDefault"); + importMapping.putIfAbsent("Sort", "org.springframework.data.domain.Sort"); + } + } + + /** Carries a parsed sort field and its direction (always "ASC" or "DESC") from the spec default. */ + private static final class SortFieldDefault { + final String field; + final String direction; + + SortFieldDefault(String field, String direction) { + this.field = field; + this.direction = direction; + } + } + + /** Carries parsed default values for page, size, and sort fields from a pageable operation. */ + private static final class PageableDefaultsData { + final Integer page; + final Integer size; + final List sortDefaults; + + PageableDefaultsData(Integer page, Integer size, List sortDefaults) { + this.page = page; + this.size = size; + this.sortDefaults = sortDefaults; + } + + boolean hasAny() { + return page != null || size != null || !sortDefaults.isEmpty(); + } + } + /** * Scans the OpenAPI spec for paginated operations whose 'sort' parameter has enum values, * builds the {@link #sortValidationEnums} registry, and registers the ValidSort.kt supporting file. diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache index 253cd1719766..033d044fb28e 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache @@ -110,7 +110,7 @@ class {{classname}}Controller({{#serviceInterface}}@Autowired(required = true) v {{>queryParams}}{{>pathParams}}{{>headerParams}}{{>cookieParams}}{{>bodyParams}}{{>formParams}}{{^-last}},{{/-last}}{{/allParams}}{{#includeHttpRequestContext}}{{#hasParams}}, {{/hasParams}}{{#swagger1AnnotationLibrary}}@ApiParam(hidden = true) {{/swagger1AnnotationLibrary}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true) {{/swagger2AnnotationLibrary}}{{#reactive}}exchange: org.springframework.web.server.ServerWebExchange{{/reactive}}{{^reactive}}request: {{javaxPackage}}.servlet.http.HttpServletRequest{{/reactive}}{{/includeHttpRequestContext}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}}, - {{/includeHttpRequestContext}}{{/hasParams}}{{#swagger1AnnotationLibrary}}@ApiParam(hidden = true) {{/swagger1AnnotationLibrary}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true) {{/swagger2AnnotationLibrary}}pageable: Pageable{{/vendorExtensions.x-spring-paginated}}{{#hasParams}} + {{/includeHttpRequestContext}}{{/hasParams}}{{#vendorExtensions.x-pageable-extra-annotation}}{{{.}}} {{/vendorExtensions.x-pageable-extra-annotation}}{{#swagger1AnnotationLibrary}}@ApiParam(hidden = true) {{/swagger1AnnotationLibrary}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true) {{/swagger2AnnotationLibrary}}pageable: Pageable{{/vendorExtensions.x-spring-paginated}}{{#hasParams}} {{/hasParams}}): {{#useResponseEntity}}ResponseEntity<{{/useResponseEntity}}{{>returnTypes}}{{#useResponseEntity}}>{{/useResponseEntity}} { return {{>returnValue}} } diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache index 04232e570533..795070dde368 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache @@ -125,7 +125,7 @@ interface {{classname}} { {{>queryParams}}{{>pathParams}}{{>headerParams}}{{>cookieParams}}{{>bodyParams}}{{>formParams}}{{^-last}},{{/-last}}{{/allParams}}{{#includeHttpRequestContext}}{{#hasParams}}, {{/hasParams}}{{#swagger1AnnotationLibrary}}@ApiParam(hidden = true) {{/swagger1AnnotationLibrary}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true) {{/swagger2AnnotationLibrary}}{{#reactive}}exchange: org.springframework.web.server.ServerWebExchange{{/reactive}}{{^reactive}}request: {{javaxPackage}}.servlet.http.HttpServletRequest{{/reactive}}{{/includeHttpRequestContext}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}}, - {{/includeHttpRequestContext}}{{/hasParams}}{{#swagger1AnnotationLibrary}}@ApiParam(hidden = true) {{/swagger1AnnotationLibrary}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true) {{/swagger2AnnotationLibrary}}pageable: Pageable{{/vendorExtensions.x-spring-paginated}}{{#hasParams}} + {{/includeHttpRequestContext}}{{/hasParams}}{{#vendorExtensions.x-pageable-extra-annotation}}{{{.}}} {{/vendorExtensions.x-pageable-extra-annotation}}{{#swagger1AnnotationLibrary}}@ApiParam(hidden = true) {{/swagger1AnnotationLibrary}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true) {{/swagger2AnnotationLibrary}}pageable: Pageable{{/vendorExtensions.x-spring-paginated}}{{#hasParams}} {{/hasParams}}): {{#useResponseEntity}}ResponseEntity<{{/useResponseEntity}}{{#useSealedResponseInterfaces}}{{#vendorExtensions.x-sealed-response-interface}}{{vendorExtensions.x-sealed-response-interface}}{{/vendorExtensions.x-sealed-response-interface}}{{^vendorExtensions.x-sealed-response-interface}}{{>returnTypes}}{{/vendorExtensions.x-sealed-response-interface}}{{/useSealedResponseInterfaces}}{{^useSealedResponseInterfaces}}{{>returnTypes}}{{/useSealedResponseInterfaces}}{{#useResponseEntity}}>{{/useResponseEntity}}{{^skipDefaultApiInterface}} { {{^isDelegate}} return {{>returnValue}} 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 9f7138467f21..7bbebc6564b1 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 @@ -4299,6 +4299,111 @@ public void generateSortValidationDoesNotGenerateValidSortFileWhenBeanValidation assertFileNotContains(petApi.toPath(), "@ValidSort"); } + // ========== PAGEABLE DEFAULTS TESTS ========== + + @Test + public void pageableDefaultsGeneratesSortDefaultsForSingleDescField() 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"); + assertFileContains(petApi.toPath(), + "@SortDefault.SortDefaults(SortDefault(sort = [\"name\"], direction = Sort.Direction.DESC))"); + assertFileContains(petApi.toPath(), "import org.springframework.data.domain.Sort"); + assertFileContains(petApi.toPath(), "import org.springframework.data.web.SortDefault"); + } + + @Test + public void pageableDefaultsGeneratesSortDefaultsForSingleAscField() 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"); + assertFileContains(petApi.toPath(), + "@SortDefault.SortDefaults(SortDefault(sort = [\"id\"], direction = Sort.Direction.ASC))"); + } + + @Test + public void pageableDefaultsGeneratesSortDefaultsForMixedDirections() 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"); + assertFileContains(petApi.toPath(), + "@SortDefault.SortDefaults(SortDefault(sort = [\"name\"], direction = Sort.Direction.DESC), SortDefault(sort = [\"id\"], direction = Sort.Direction.ASC))"); + } + + @Test + public void pageableDefaultsGeneratesPageableDefaultForPageAndSize() 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"); + assertFileContains(petApi.toPath(), "@PageableDefault(page = 0, size = 25)"); + assertFileContains(petApi.toPath(), "import org.springframework.data.web.PageableDefault"); + } + + @Test + public void pageableDefaultsGeneratesBothAnnotationsWhenAllDefaultsPresent() 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"); + String content = Files.readString(petApi.toPath()); + + int methodStart = content.indexOf("fun findPetsWithAllDefaults("); + Assert.assertTrue(methodStart >= 0, "findPetsWithAllDefaults method should exist"); + String methodBlock = content.substring(Math.max(0, methodStart - 500), methodStart + 500); + + Assert.assertTrue(methodBlock.contains("@PageableDefault(page = 0, size = 10)"), + "findPetsWithAllDefaults should have @PageableDefault(page = 0, size = 10)"); + Assert.assertTrue(methodBlock.contains( + "@SortDefault.SortDefaults(SortDefault(sort = [\"name\"], direction = Sort.Direction.DESC), SortDefault(sort = [\"id\"], direction = Sort.Direction.ASC))"), + "findPetsWithAllDefaults should have @SortDefault.SortDefaults with both fields"); + } + + @Test + public void pageableDefaultsDoesNotAnnotateNonPageableOperation() 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"); + String content = Files.readString(petApi.toPath()); + + // findPetsNonPaginatedWithSortEnum has no x-spring-paginated, so no pageable annotations + int methodStart = content.indexOf("fun findPetsNonPaginatedWithSortEnum("); + Assert.assertTrue(methodStart >= 0, "findPetsNonPaginatedWithSortEnum method should exist"); + String methodBlock = content.substring(Math.max(0, methodStart - 500), methodStart); + Assert.assertFalse(methodBlock.contains("@SortDefault"), + "Non-paginated operation should not have @SortDefault"); + Assert.assertFalse(methodBlock.contains("@PageableDefault"), + "Non-paginated operation should not have @PageableDefault"); + } + @Test public void autoXSpringPaginatedDetectsAllThreeParams() throws Exception { Map additionalProperties = new HashMap<>(); 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 552607803f11..519a764c89d9 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 @@ -178,6 +178,174 @@ paths: type: array items: $ref: '#/components/schemas/Pet' + + # ---- Pageable defaults test cases ---- + + /pet/findWithSortDefaultOnly: + get: + tags: + - pet + summary: Find pets — sort default only (single field DESC, no page/size defaults) + operationId: findPetsWithSortDefaultOnly + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + - name: size + in: query + schema: + type: integer + - name: sort + in: query + schema: + type: string + default: "name,desc" + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /pet/findWithSortDefaultAsc: + get: + tags: + - pet + summary: Find pets — sort default only (single field, no explicit direction defaults to ASC) + operationId: findPetsWithSortDefaultAsc + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + - name: size + in: query + schema: + type: integer + - name: sort + in: query + schema: + type: string + default: "id" + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /pet/findWithMixedSortDefaults: + get: + tags: + - pet + summary: Find pets — multiple sort defaults with mixed directions (array sort param) + operationId: findPetsWithMixedSortDefaults + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + - name: size + in: query + schema: + type: integer + - name: sort + in: query + style: form + explode: true + schema: + type: array + items: + type: string + default: + - "name,desc" + - "id,asc" + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /pet/findWithPageSizeDefaultsOnly: + get: + tags: + - pet + summary: Find pets — page and size defaults only, no sort default + operationId: findPetsWithPageSizeDefaultsOnly + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + default: 0 + - name: size + in: query + schema: + type: integer + default: 25 + - name: sort + in: query + schema: + type: string + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /pet/findWithAllDefaults: + get: + tags: + - pet + summary: Find pets — page, size, and mixed sort defaults all present + operationId: findPetsWithAllDefaults + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + default: 0 + - name: size + in: query + schema: + type: integer + default: 10 + - name: sort + in: query + style: form + explode: true + schema: + type: array + items: + type: string + default: + - "name,desc" + - "id,asc" + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' components: schemas: PetSort: From 919ee1839c153516f8cbdf1847369ab206f0715f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1chym=20Metli=C4=8Dka?= Date: Mon, 13 Apr 2026 17:01:50 +0200 Subject: [PATCH 08/15] feat: enhance pageable validation with sort enum support --- .../languages/KotlinSpringServerCodegen.java | 20 +++----- .../kotlin-spring/validSort.mustache | 47 ++++++++++++------- .../spring/KotlinSpringServerCodegenTest.java | 18 ++++++- 3 files changed, 53 insertions(+), 32 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 87c00564dc81..e6988fabe57a 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 @@ -1059,27 +1059,21 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation } // #8315 Remove matching Spring Data Web default query params if 'x-spring-paginated' with Pageable is used - // Before removal, capture sort enum values for @ValidSort if generateSortValidation is enabled + // Build pageable parameter annotations (@ValidSort, @PageableDefault, @SortDefault.SortDefaults) + List pageableAnnotations = new ArrayList<>(); + 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(", ")); - String validSortAnnotation = "@ValidSort(allowedValues = [" + allowedValuesStr + "])"; - - Object existingAnnotation = codegenOperation.vendorExtensions.get("x-operation-extra-annotation"); - List existingAnnotations = DefaultCodegen.getObjectAsStringList(existingAnnotation); - List updatedAnnotations = new ArrayList<>(existingAnnotations); - updatedAnnotations.add(validSortAnnotation); - codegenOperation.vendorExtensions.put("x-operation-extra-annotation", updatedAnnotations); - + pageableAnnotations.add("@ValidSort(allowedValues = [" + allowedValuesStr + "])"); codegenOperation.imports.add("ValidSort"); } // Generate @PageableDefault / @SortDefault.SortDefaults annotations if defaults are present if (pageableDefaultsRegistry.containsKey(codegenOperation.operationId)) { PageableDefaultsData defaults = pageableDefaultsRegistry.get(codegenOperation.operationId); - List pageableAnnotations = new ArrayList<>(); if (defaults.page != null || defaults.size != null) { List attrs = new ArrayList<>(); @@ -1097,10 +1091,10 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation codegenOperation.imports.add("SortDefault"); codegenOperation.imports.add("Sort"); } + } - if (!pageableAnnotations.isEmpty()) { - codegenOperation.vendorExtensions.put("x-pageable-extra-annotation", pageableAnnotations); - } + 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)); diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/validSort.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/validSort.mustache index f0066fcc788e..7b3f7cf0adbd 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/validSort.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/validSort.mustache @@ -4,18 +4,25 @@ import {{javaxPackage}}.validation.Constraint import {{javaxPackage}}.validation.ConstraintValidator import {{javaxPackage}}.validation.ConstraintValidatorContext import {{javaxPackage}}.validation.Payload -import {{javaxPackage}}.validation.constraintvalidation.SupportedValidationTarget -import {{javaxPackage}}.validation.constraintvalidation.ValidationTarget import org.springframework.data.domain.Pageable /** - * Validates that sort properties in a [Pageable] parameter match the allowed values. + * Validates that sort properties in the annotated [Pageable] parameter match the allowed values. * - * This annotation can only be applied to methods that have a [Pageable] parameter. - * The validator checks that each sort property and direction combination in the [Pageable] - * matches one of the strings specified in [allowedValues]. + * Apply directly on a `pageable: Pageable` parameter. The validator checks that each sort + * property and direction combination in the [Pageable] matches one of the strings specified + * in [allowedValues]. * - * Expected value format: `"property,direction"` (e.g. `"id,asc"`, `"name,desc"`). + * Two formats are accepted in [allowedValues]: + * - `"property,direction"` — permits only the specific direction (e.g. `"id,asc"`, `"name,desc"`). + * Direction matching is case-insensitive: `"id,ASC"` and `"id,asc"` are treated identically. + * - `"property"` — permits any direction for that property (e.g. `"id"` matches `sort=id,asc` + * and `sort=id,desc`). Note: because Spring always normalises a bare `sort=id` to ascending + * before the validator runs, bare property names in [allowedValues] effectively allow all + * directions — the original omission of a direction cannot be detected. + * + * Both formats may be mixed freely. For example `["id", "name,desc"]` allows `id` in any + * direction but restricts `name` to descending only. * * @property allowedValues The allowed sort strings (e.g. `["id,asc", "id,desc"]`) * @property groups Validation groups (optional) @@ -25,7 +32,7 @@ import org.springframework.data.domain.Pageable @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) @Constraint(validatedBy = [SortValidator::class]) -@Target(AnnotationTarget.FUNCTION) +@Target(AnnotationTarget.VALUE_PARAMETER) annotation class ValidSort( val allowedValues: Array, val groups: Array> = [], @@ -33,26 +40,30 @@ annotation class ValidSort( val message: String = "Invalid sort column" ) -@SupportedValidationTarget(ValidationTarget.PARAMETERS) -class SortValidator : ConstraintValidator> { +class SortValidator : ConstraintValidator { private lateinit var allowedValues: Set override fun initialize(constraintAnnotation: ValidSort) { - allowedValues = constraintAnnotation.allowedValues.toSet() + allowedValues = constraintAnnotation.allowedValues.map { entry -> + DIRECTION_ASC_SUFFIX.replace(entry, ",asc") + .let { DIRECTION_DESC_SUFFIX.replace(it, ",desc") } + }.toSet() + } + + private companion object { + val DIRECTION_ASC_SUFFIX = Regex(",ASC$", RegexOption.IGNORE_CASE) + val DIRECTION_DESC_SUFFIX = Regex(",DESC$", RegexOption.IGNORE_CASE) } - override fun isValid(parameters: Array?, context: ConstraintValidatorContext): Boolean { - val pageable = parameters?.filterIsInstance()?.firstOrNull() - ?: throw IllegalStateException( - "@ValidSort can only be used on methods with a Pageable parameter. " + - "Ensure the annotated method has a parameter of type org.springframework.data.domain.Pageable." - ) + override fun isValid(pageable: Pageable?, context: ConstraintValidatorContext): Boolean { + if (pageable == null || pageable.sort.isUnsorted) return true val invalid = pageable.sort .foldIndexed(emptyMap()) { index, acc, order -> val sortValue = "${order.property},${order.direction.name.lowercase()}" - if (sortValue !in allowedValues) acc + (index to order.property) + // Accept "property,direction" (exact match) OR "property" alone (any direction allowed) + if (sortValue !in allowedValues && order.property !in allowedValues) acc + (index to order.property) else acc } .toSortedMap() 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 7bbebc6564b1..1b32ddd23328 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 @@ -4177,6 +4177,21 @@ public void generateSortValidationAddsAnnotationForExplicitPaginated() throws Ex File petApi = files.get("PetApi.kt"); assertFileContains(petApi.toPath(), "@ValidSort(allowedValues = [\"id,asc\", \"id,desc\", \"name,asc\", \"name,desc\"])"); assertFileContains(petApi.toPath(), "import org.openapitools.configuration.ValidSort"); + + // @ValidSort must be a parameter annotation — appears in the 500-char window AFTER `fun findPetsWithSortEnum(` + String content = Files.readString(petApi.toPath()); + int methodStart = content.indexOf("fun findPetsWithSortEnum("); + Assert.assertTrue(methodStart >= 0, "findPetsWithSortEnum method should exist"); + String paramBlock = content.substring(methodStart, Math.min(content.length(), methodStart + 500)); + Assert.assertTrue(paramBlock.contains("@ValidSort(allowedValues = [\"id,asc\", \"id,desc\", \"name,asc\", \"name,desc\"])"), + "@ValidSort should appear as a parameter annotation (inside the method signature, after `fun`)"); + Assert.assertTrue(paramBlock.contains("pageable: Pageable"), + "findPetsWithSortEnum should have a pageable: Pageable parameter"); + + // @ValidSort must NOT be a method-level annotation (not in the 500-char prefix before `fun`) + String prefixBlock = content.substring(Math.max(0, methodStart - 500), methodStart); + Assert.assertFalse(prefixBlock.contains("@ValidSort"), + "@ValidSort should be a parameter annotation, not a method-level annotation"); } @Test @@ -4265,7 +4280,8 @@ public void generateSortValidationGeneratesValidSortFile() throws Exception { assertFileContains(validSortFile.toPath(), "annotation class ValidSort"); assertFileContains(validSortFile.toPath(), "class SortValidator"); assertFileContains(validSortFile.toPath(), "val allowedValues: Array"); - assertFileContains(validSortFile.toPath(), "allowedValues.toSet()"); + assertFileContains(validSortFile.toPath(), "DIRECTION_ASC_SUFFIX"); + assertFileContains(validSortFile.toPath(), "DIRECTION_DESC_SUFFIX"); } @Test From 9af137fce3dfdb6ed1b65599501240bdb622ca62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1chym=20Metli=C4=8Dka?= Date: Tue, 14 Apr 2026 18:42:58 +0200 Subject: [PATCH 09/15] feat: add pageable constraint validation tests for size and page limits --- .../languages/KotlinSpringServerCodegen.java | 228 +++---------- .../languages/SpringPageableScanUtils.java | 303 ++++++++++++++++++ .../kotlin-spring/validPageable.mustache | 80 +++++ .../spring/KotlinSpringServerCodegenTest.java | 98 ++++++ .../3_0/spring/petstore-sort-validation.yaml | 61 ++++ 5 files changed, 588 insertions(+), 182 deletions(-) create mode 100644 modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java create mode 100644 modules/openapi-generator/src/main/resources/kotlin-spring/validPageable.mustache 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 e6988fabe57a..067365ececff 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 @@ -17,7 +17,6 @@ package org.openapitools.codegen.languages; import com.google.common.collect.ImmutableMap; -import com.fasterxml.jackson.databind.node.ArrayNode; import com.samskivert.mustache.Mustache; import com.samskivert.mustache.Mustache.Lambda; import com.samskivert.mustache.Template; @@ -103,6 +102,7 @@ public class KotlinSpringServerCodegen extends AbstractKotlinCodegen public static final String USE_REQUEST_MAPPING_ON_INTERFACE = "useRequestMappingOnInterface"; public static final String AUTO_X_SPRING_PAGINATED = "autoXSpringPaginated"; public static final String GENERATE_SORT_VALIDATION = "generateSortValidation"; + public static final String GENERATE_PAGEABLE_CONSTRAINT_VALIDATION = "generatePageableConstraintValidation"; public static final String USE_SEALED_RESPONSE_INTERFACES = "useSealedResponseInterfaces"; public static final String COMPANION_OBJECT = "companionObject"; @@ -170,6 +170,7 @@ public String getDescription() { @Setter private boolean useResponseEntity = true; @Setter private boolean autoXSpringPaginated = false; @Setter private boolean generateSortValidation = false; + @Setter private boolean generatePageableConstraintValidation = false; @Setter private boolean useSealedResponseInterfaces = false; @Setter private boolean companionObject = false; @@ -190,7 +191,10 @@ public String getDescription() { private Map> sortValidationEnums = new HashMap<>(); // Map from operationId to pageable defaults for @PageableDefault/@SortDefault annotation generation - private Map pageableDefaultsRegistry = new HashMap<>(); + private Map pageableDefaultsRegistry = new HashMap<>(); + + // Map from operationId to pageable constraints for @ValidPageable annotation generation + private Map pageableConstraintsRegistry = new HashMap<>(); public KotlinSpringServerCodegen() { super(); @@ -285,6 +289,7 @@ public KotlinSpringServerCodegen() { addOption(SCHEMA_IMPLEMENTS_FIELDS, "A map of single field or a list of fields per schema name that should be prepended with `override` (serves similar purpose as `x-kotlin-implements-fields`, but is fully decoupled from the api spec). Example: yaml `schemaImplementsFields: {Pet: id, Category: [name, id], Dog: [bark, breed]}` marks fields to be prepended with `override` in schemas `Pet` (field `id`), `Category` (fields `name`, `id`) and `Dog` (fields `bark`, `breed`)", "empty map"); addSwitch(AUTO_X_SPRING_PAGINATED, "Automatically add x-spring-paginated to operations that have 'page', 'size', and 'sort' query parameters. When enabled, operations with all three parameters will have Pageable support automatically applied. Operations with x-spring-paginated explicitly set to false will not be auto-detected.", autoXSpringPaginated); addSwitch(GENERATE_SORT_VALIDATION, "Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to paginated operations whose 'sort' parameter has enum values. Requires useBeanValidation=true and library=spring-boot.", generateSortValidation); + addSwitch(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to paginated operations whose 'page' or 'size' parameter has a maximum constraint. Requires useBeanValidation=true and library=spring-boot.", generatePageableConstraintValidation); addSwitch(COMPANION_OBJECT, "Whether to generate companion objects in data classes, enabling companion extensions.", companionObject); supportedLibraries.put(SPRING_BOOT, "Spring-boot Server application."); supportedLibraries.put(SPRING_CLOUD_LIBRARY, @@ -721,6 +726,10 @@ public void processOpts() { this.setGenerateSortValidation(convertPropertyToBoolean(GENERATE_SORT_VALIDATION)); } writePropertyBack(GENERATE_SORT_VALIDATION, generateSortValidation); + if (additionalProperties.containsKey(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION) && library.equals(SPRING_BOOT)) { + this.setGeneratePageableConstraintValidation(convertPropertyToBoolean(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION)); + } + writePropertyBack(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, generatePageableConstraintValidation); if (isUseSpringBoot3() && isUseSpringBoot4()) { throw new IllegalArgumentException("Choose between Spring Boot 3 and Spring Boot 4"); } @@ -1059,9 +1068,18 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation } // #8315 Remove matching Spring Data Web default query params if 'x-spring-paginated' with Pageable is used - // Build pageable parameter annotations (@ValidSort, @PageableDefault, @SortDefault.SortDefaults) + // Build pageable parameter annotations (@ValidPageable, @ValidSort, @PageableDefault, @SortDefault.SortDefaults) List pageableAnnotations = new ArrayList<>(); + if (generatePageableConstraintValidation && useBeanValidation && pageableConstraintsRegistry.containsKey(codegenOperation.operationId)) { + SpringPageableScanUtils.PageableConstraintsData constraints = pageableConstraintsRegistry.get(codegenOperation.operationId); + List attrs = new ArrayList<>(); + if (constraints.maxSize >= 0) attrs.add("maxSize = " + constraints.maxSize); + if (constraints.maxPage >= 0) attrs.add("maxPage = " + constraints.maxPage); + pageableAnnotations.add("@ValidPageable(" + String.join(", ", attrs) + ")"); + codegenOperation.imports.add("ValidPageable"); + } + if (generateSortValidation && useBeanValidation && sortValidationEnums.containsKey(codegenOperation.operationId)) { List allowedSortValues = sortValidationEnums.get(codegenOperation.operationId); String allowedValuesStr = allowedSortValues.stream() @@ -1073,7 +1091,7 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation // Generate @PageableDefault / @SortDefault.SortDefaults annotations if defaults are present if (pageableDefaultsRegistry.containsKey(codegenOperation.operationId)) { - PageableDefaultsData defaults = pageableDefaultsRegistry.get(codegenOperation.operationId); + SpringPageableScanUtils.PageableDefaultsData defaults = pageableDefaultsRegistry.get(codegenOperation.operationId); if (defaults.page != null || defaults.size != null) { List attrs = new ArrayList<>(); @@ -1113,11 +1131,30 @@ public void preprocessOpenAPI(OpenAPI openAPI) { } if (SPRING_BOOT.equals(library) && generateSortValidation && useBeanValidation) { - scanSortValidationEnums(openAPI); + sortValidationEnums = SpringPageableScanUtils.scanSortValidationEnums(openAPI, autoXSpringPaginated); + if (!sortValidationEnums.isEmpty()) { + importMapping.putIfAbsent("ValidSort", configPackage + ".ValidSort"); + supportingFiles.add(new SupportingFile("validSort.mustache", + (sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidSort.kt")); + } } if (SPRING_BOOT.equals(library)) { - scanPageableDefaults(openAPI); + pageableDefaultsRegistry = SpringPageableScanUtils.scanPageableDefaults(openAPI, autoXSpringPaginated); + if (!pageableDefaultsRegistry.isEmpty()) { + importMapping.putIfAbsent("PageableDefault", "org.springframework.data.web.PageableDefault"); + importMapping.putIfAbsent("SortDefault", "org.springframework.data.web.SortDefault"); + importMapping.putIfAbsent("Sort", "org.springframework.data.domain.Sort"); + } + } + + if (SPRING_BOOT.equals(library) && generatePageableConstraintValidation && useBeanValidation) { + pageableConstraintsRegistry = SpringPageableScanUtils.scanPageableConstraints(openAPI, autoXSpringPaginated); + if (!pageableConstraintsRegistry.isEmpty()) { + importMapping.putIfAbsent("ValidPageable", configPackage + ".ValidPageable"); + supportingFiles.add(new SupportingFile("validPageable.mustache", + (sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidPageable.kt")); + } } if (!additionalProperties.containsKey(TITLE)) { @@ -1186,184 +1223,11 @@ public void preprocessOpenAPI(OpenAPI openAPI) { } /** - * Scans the OpenAPI spec for pageable operations whose page/size/sort parameters have default values, - * builds the {@link #pageableDefaultsRegistry}, and registers required import mappings. - * Called from {@link #preprocessOpenAPI} for all spring-boot generations. - */ - private void scanPageableDefaults(OpenAPI openAPI) { - if (openAPI.getPaths() == null) { - return; - } - for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { - for (Operation operation : pathEntry.getValue().readOperations()) { - String operationId = operation.getOperationId(); - if (operationId == null || !willBePageable(operation)) { - continue; - } - if (operation.getParameters() == null) { - continue; - } - Integer pageDefault = null; - Integer sizeDefault = null; - List sortDefaults = new ArrayList<>(); - - 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.getDefault() == null) { - continue; - } - Object defaultValue = schema.getDefault(); - switch (param.getName()) { - case "page": - if (defaultValue instanceof Number) { - pageDefault = ((Number) defaultValue).intValue(); - } - break; - case "size": - if (defaultValue instanceof Number) { - sizeDefault = ((Number) defaultValue).intValue(); - } - break; - case "sort": - List sortValues = new ArrayList<>(); - if (defaultValue instanceof String) { - sortValues.add((String) defaultValue); - } else if (defaultValue instanceof ArrayNode) { - ((ArrayNode) defaultValue).forEach(node -> sortValues.add(node.asText())); - } else if (defaultValue instanceof List) { - for (Object item : (List) defaultValue) { - sortValues.add(item.toString()); - } - } - for (String sortStr : sortValues) { - String[] parts = sortStr.split(",", 2); - String field = parts[0].trim(); - String direction = parts.length > 1 ? parts[1].trim().toUpperCase(Locale.ROOT) : "ASC"; - sortDefaults.add(new SortFieldDefault(field, direction)); - } - break; - default: - break; - } - } - - PageableDefaultsData data = new PageableDefaultsData(pageDefault, sizeDefault, sortDefaults); - if (data.hasAny()) { - pageableDefaultsRegistry.put(operationId, data); - } - } - } - if (!pageableDefaultsRegistry.isEmpty()) { - importMapping.putIfAbsent("PageableDefault", "org.springframework.data.web.PageableDefault"); - importMapping.putIfAbsent("SortDefault", "org.springframework.data.web.SortDefault"); - importMapping.putIfAbsent("Sort", "org.springframework.data.domain.Sort"); - } - } - - /** Carries a parsed sort field and its direction (always "ASC" or "DESC") from the spec default. */ - private static final class SortFieldDefault { - final String field; - final String direction; - - SortFieldDefault(String field, String direction) { - this.field = field; - this.direction = direction; - } - } - - /** Carries parsed default values for page, size, and sort fields from a pageable operation. */ - private static final class PageableDefaultsData { - final Integer page; - final Integer size; - final List sortDefaults; - - PageableDefaultsData(Integer page, Integer size, List sortDefaults) { - this.page = page; - this.size = size; - this.sortDefaults = sortDefaults; - } - - boolean hasAny() { - return page != null || size != null || !sortDefaults.isEmpty(); - } - } - - /** - * Scans the OpenAPI spec for paginated operations whose 'sort' parameter has enum values, - * builds the {@link #sortValidationEnums} registry, and registers the ValidSort.kt supporting file. - * Called from {@link #preprocessOpenAPI} when {@code generateSortValidation} is enabled. - */ - private void scanSortValidationEnums(OpenAPI openAPI) { - if (openAPI.getPaths() == null) { - return; - } - boolean foundAny = false; - for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { - for (Operation operation : pathEntry.getValue().readOperations()) { - String operationId = operation.getOperationId(); - if (operationId == null || !willBePageable(operation)) { - continue; - } - if (operation.getParameters() == null) { - continue; - } - for (Parameter param : operation.getParameters()) { - if (!"sort".equals(param.getName())) { - continue; - } - Schema schema = param.getSchema(); - if (schema == null) { - continue; - } - if (schema.get$ref() != null) { - schema = ModelUtils.getReferencedSchema(openAPI, schema); - } - if (schema == null || schema.getEnum() == null || schema.getEnum().isEmpty()) { - continue; - } - List enumValues = schema.getEnum().stream() - .map(Object::toString) - .collect(Collectors.toList()); - sortValidationEnums.put(operationId, enumValues); - foundAny = true; - } - } - } - if (foundAny) { - importMapping.putIfAbsent("ValidSort", configPackage + ".ValidSort"); - supportingFiles.add(new SupportingFile("validSort.mustache", - (sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidSort.kt")); - } - } - - /** - * Returns true if the given operation will have a Pageable parameter injected — either because - * it has {@code x-spring-paginated: true} explicitly, or because {@link #autoXSpringPaginated} - * is enabled and the operation has all three default pagination query parameters (page, size, sort). + * Returns true if the given operation will have a Pageable parameter injected. + * Delegates to {@link SpringPageableScanUtils#willBePageable}. */ private boolean willBePageable(Operation operation) { - 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()); - return paramNames.containsAll(Arrays.asList("page", "size", "sort")); - } - return false; + return SpringPageableScanUtils.willBePageable(operation, autoXSpringPaginated); } @Override 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 new file mode 100644 index 000000000000..ba8e0ebd6674 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringPageableScanUtils.java @@ -0,0 +1,303 @@ +/* + * Copyright 2018 OpenAPI-Generator Contributors (https://openapi-generator.tech) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openapitools.codegen.languages; + +import com.fasterxml.jackson.databind.node.ArrayNode; +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 org.openapitools.codegen.utils.ModelUtils; + +import java.math.BigDecimal; +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). + * + *

Used by both {@link KotlinSpringServerCodegen} and (future) Java Spring codegen to share + * scan logic. Only the mustache templates and their registration remain language-specific.

+ */ +public final class SpringPageableScanUtils { + + private SpringPageableScanUtils() {} + + // ------------------------------------------------------------------------- + // Data classes + // ------------------------------------------------------------------------- + + /** Carries a parsed sort field and its direction (always "ASC" or "DESC") from the spec default. */ + public static final class SortFieldDefault { + public final String field; + public final String direction; + + public SortFieldDefault(String field, String direction) { + this.field = field; + this.direction = direction; + } + } + + /** Carries parsed default values for page, size, and sort fields from a pageable operation. */ + public static final class PageableDefaultsData { + public final Integer page; + public final Integer size; + public final List sortDefaults; + + public PageableDefaultsData(Integer page, Integer size, List sortDefaults) { + this.page = page; + this.size = size; + this.sortDefaults = sortDefaults; + } + + public boolean hasAny() { + return page != null || size != null || !sortDefaults.isEmpty(); + } + } + + /** + * 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). + */ + 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; + + public PageableConstraintsData(int maxPage, int maxSize) { + this.maxPage = maxPage; + this.maxSize = maxSize; + } + + public boolean hasAny() { + return maxPage >= 0 || maxSize >= 0; + } + } + + // ------------------------------------------------------------------------- + // Scan methods + // ------------------------------------------------------------------------- + + /** + * Returns {@code true} if the given operation will have a Pageable parameter injected — + * either because it has {@code x-spring-paginated: true} explicitly, or because + * {@code autoXSpringPaginated} is enabled and the operation has all three default + * pagination query parameters (page, size, sort). + */ + public static boolean willBePageable(Operation operation, boolean autoXSpringPaginated) { + if (operation.getExtensions() != null) { + Object paginated = operation.getExtensions().get("x-spring-paginated"); + if (Boolean.FALSE.equals(paginated)) { + return false; + } + if (Boolean.TRUE.equals(paginated)) { + return true; + } + } + if (autoXSpringPaginated && operation.getParameters() != null) { + Set paramNames = operation.getParameters().stream() + .map(Parameter::getName) + .collect(Collectors.toSet()); + return paramNames.containsAll(Arrays.asList("page", "size", "sort")); + } + return false; + } + + /** + * Scans all pageable operations for a {@code sort} parameter with enum values. + * + * @return map from operationId to list of allowed sort strings (e.g. {@code ["id,asc", "id,desc"]}) + */ + public static Map> scanSortValidationEnums( + OpenAPI openAPI, boolean autoXSpringPaginated) { + Map> result = new LinkedHashMap<>(); + if (openAPI.getPaths() == null) { + return result; + } + 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) { + continue; + } + for (Parameter param : operation.getParameters()) { + if (!"sort".equals(param.getName())) { + continue; + } + Schema schema = param.getSchema(); + if (schema == null) { + continue; + } + if (schema.get$ref() != null) { + schema = ModelUtils.getReferencedSchema(openAPI, schema); + } + if (schema == null || schema.getEnum() == null || schema.getEnum().isEmpty()) { + continue; + } + List enumValues = schema.getEnum().stream() + .map(Object::toString) + .collect(Collectors.toList()); + result.put(operationId, enumValues); + } + } + } + return result; + } + + /** + * Scans all pageable operations for default values on {@code page}, {@code size}, + * and {@code sort} parameters. + * + * @return map from operationId to {@link PageableDefaultsData} (only operations with at + * least one default are included) + */ + public static Map scanPageableDefaults( + OpenAPI openAPI, boolean autoXSpringPaginated) { + Map result = new LinkedHashMap<>(); + if (openAPI.getPaths() == null) { + return result; + } + 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) { + continue; + } + Integer pageDefault = null; + Integer sizeDefault = null; + List sortDefaults = new ArrayList<>(); + + 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.getDefault() == null) { + continue; + } + Object defaultValue = schema.getDefault(); + switch (param.getName()) { + case "page": + if (defaultValue instanceof Number) { + pageDefault = ((Number) defaultValue).intValue(); + } + break; + case "size": + if (defaultValue instanceof Number) { + sizeDefault = ((Number) defaultValue).intValue(); + } + break; + case "sort": + List sortValues = new ArrayList<>(); + if (defaultValue instanceof String) { + sortValues.add((String) defaultValue); + } else if (defaultValue instanceof ArrayNode) { + ((ArrayNode) defaultValue).forEach(node -> sortValues.add(node.asText())); + } else if (defaultValue instanceof List) { + for (Object item : (List) defaultValue) { + sortValues.add(item.toString()); + } + } + for (String sortStr : sortValues) { + String[] parts = sortStr.split(",", 2); + String field = parts[0].trim(); + String direction = parts.length > 1 ? parts[1].trim().toUpperCase(Locale.ROOT) : "ASC"; + sortDefaults.add(new SortFieldDefault(field, direction)); + } + break; + default: + break; + } + } + + PageableDefaultsData data = new PageableDefaultsData(pageDefault, sizeDefault, sortDefaults); + if (data.hasAny()) { + result.put(operationId, data); + } + } + } + return result; + } + + /** + * Scans all pageable operations for {@code maximum:} constraints on {@code page} and + * {@code size} parameters. + * + * @return map from operationId to {@link PageableConstraintsData} (only operations with + * at least one {@code maximum:} constraint are included) + */ + public static Map scanPageableConstraints( + OpenAPI openAPI, boolean autoXSpringPaginated) { + Map result = new LinkedHashMap<>(); + if (openAPI.getPaths() == null) { + return result; + } + 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) { + continue; + } + int maxPage = -1; + int maxSize = -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(); + switch (param.getName()) { + case "page": + maxPage = maximum; + break; + case "size": + maxSize = maximum; + break; + default: + break; + } + } + PageableConstraintsData data = new PageableConstraintsData(maxPage, maxSize); + if (data.hasAny()) { + result.put(operationId, data); + } + } + } + return result; + } +} diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/validPageable.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/validPageable.mustache new file mode 100644 index 000000000000..16b8050aa14d --- /dev/null +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/validPageable.mustache @@ -0,0 +1,80 @@ +package {{configPackage}} + +import {{javaxPackage}}.validation.Constraint +import {{javaxPackage}}.validation.ConstraintValidator +import {{javaxPackage}}.validation.ConstraintValidatorContext +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. + * + * 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` + * + * Use [NO_LIMIT] (= -1, the default) to leave an attribute unconstrained. + * + * Constraining [maxPage] is useful to prevent deep-pagination attacks, where a large page + * offset (e.g. `?page=100000&size=20`) causes an expensive `OFFSET` query on the database. + * + * @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 groups Validation groups (optional) + * @property payload Additional payload (optional) + * @property message Validation error message (default: "Invalid page request") + */ +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +@Constraint(validatedBy = [PageableConstraintValidator::class]) +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class ValidPageable( + val maxSize: Int = ValidPageable.NO_LIMIT, + val maxPage: Int = ValidPageable.NO_LIMIT, + val groups: Array> = [], + val payload: Array> = [], + val message: String = "Invalid page request" +) { + companion object { + const val NO_LIMIT = -1 + } +} + +class PageableConstraintValidator : ConstraintValidator { + + private var maxSize = ValidPageable.NO_LIMIT + private var maxPage = ValidPageable.NO_LIMIT + + override fun initialize(constraintAnnotation: ValidPageable) { + maxSize = constraintAnnotation.maxSize + maxPage = constraintAnnotation.maxPage + } + + override fun isValid(pageable: Pageable?, context: ConstraintValidatorContext): Boolean { + if (pageable == null) return true + + var valid = true + context.disableDefaultConstraintViolation() + + if (maxSize >= 0 && pageable.pageSize > maxSize) { + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate}: page size ${pageable.pageSize} exceeds maximum $maxSize" + ) + .addPropertyNode("size") + .addConstraintViolation() + valid = false + } + + if (maxPage >= 0 && pageable.pageNumber > maxPage) { + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate}: page number ${pageable.pageNumber} exceeds maximum $maxPage" + ) + .addPropertyNode("page") + .addConstraintViolation() + valid = false + } + + return valid + } +} 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 1b32ddd23328..5bd01b04236d 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 @@ -4160,6 +4160,104 @@ private Map generateFromContract( .collect(Collectors.toMap(File::getName, Function.identity())); } + // ========== GENERATE PAGEABLE CONSTRAINT VALIDATION TESTS ========== + + @Test + public void generatePageableConstraintValidationAddsSizeConstraint() 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()); + + // findPetsWithSizeConstraint has maximum: 100 on size only + int methodStart = content.indexOf("fun findPetsWithSizeConstraint("); + Assert.assertTrue(methodStart >= 0, "findPetsWithSizeConstraint method should exist"); + String paramBlock = content.substring(methodStart, Math.min(content.length(), methodStart + 500)); + Assert.assertTrue(paramBlock.contains("@ValidPageable(maxSize = 100)"), + "@ValidPageable(maxSize = 100) should appear on the pageable parameter"); + Assert.assertFalse(paramBlock.contains("maxPage"), + "maxPage should not appear when only size has a maximum constraint"); + + assertFileContains(petApi.toPath(), "import org.openapitools.configuration.ValidPageable"); + } + + @Test + public void generatePageableConstraintValidationAddsPageAndSizeConstraint() 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()); + + // findPetsWithPageAndSizeConstraint has maximum: 999 on page and maximum: 50 on size + int methodStart = content.indexOf("fun findPetsWithPageAndSizeConstraint("); + Assert.assertTrue(methodStart >= 0, "findPetsWithPageAndSizeConstraint method should exist"); + String paramBlock = content.substring(methodStart, Math.min(content.length(), methodStart + 500)); + Assert.assertTrue(paramBlock.contains("@ValidPageable(maxSize = 50, maxPage = 999)"), + "@ValidPageable(maxSize = 50, maxPage = 999) should appear on the pageable parameter"); + } + + @Test + public void generatePageableConstraintValidationGeneratesValidPageableFile() 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 validPageableFile = files.get("ValidPageable.kt"); + Assert.assertNotNull(validPageableFile, "ValidPageable.kt should be generated when generatePageableConstraintValidation=true"); + assertFileContains(validPageableFile.toPath(), "annotation class ValidPageable"); + assertFileContains(validPageableFile.toPath(), "class PageableConstraintValidator"); + assertFileContains(validPageableFile.toPath(), "val maxSize: Int"); + assertFileContains(validPageableFile.toPath(), "val maxPage: Int"); + assertFileContains(validPageableFile.toPath(), "NO_LIMIT"); + } + + @Test + public void generatePageableConstraintValidationDoesNotGenerateFileWhenDisabled() throws Exception { + Map additionalProperties = new HashMap<>(); + additionalProperties.put(USE_TAGS, "true"); + additionalProperties.put(INTERFACE_ONLY, "true"); + additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); + // NOT setting GENERATE_PAGEABLE_CONSTRAINT_VALIDATION (defaults to false) + + Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); + + Assert.assertNull(files.get("ValidPageable.kt"), "ValidPageable.kt should NOT be generated when generatePageableConstraintValidation=false"); + File petApi = files.get("PetApi.kt"); + assertFileNotContains(petApi.toPath(), "@ValidPageable"); + } + + @Test + public void generatePageableConstraintValidationDoesNotGenerateFileWhenBeanValidationDisabled() 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"); + additionalProperties.put(USE_BEANVALIDATION, "false"); + + Map files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); + + Assert.assertNull(files.get("ValidPageable.kt"), "ValidPageable.kt should NOT be generated when useBeanValidation=false"); + File petApi = files.get("PetApi.kt"); + assertFileNotContains(petApi.toPath(), "@ValidPageable"); + } + // ========== AUTO X-SPRING-PAGINATED TESTS ========== // ========== GENERATE SORT VALIDATION TESTS ========== 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 519a764c89d9..a795ff7a2e54 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 @@ -346,6 +346,67 @@ paths: type: array items: $ref: '#/components/schemas/Pet' + /pet/findWithSizeConstraint: + get: + tags: + - pet + summary: Find pets — size has maximum constraint only + operationId: findPetsWithSizeConstraint + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + - name: size + in: query + schema: + type: integer + maximum: 100 + - name: sort + in: query + schema: + type: string + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /pet/findWithPageAndSizeConstraint: + get: + tags: + - pet + summary: Find pets — both page and size have maximum constraints + operationId: findPetsWithPageAndSizeConstraint + x-spring-paginated: true + parameters: + - name: page + in: query + schema: + type: integer + maximum: 999 + - name: size + in: query + schema: + type: integer + maximum: 50 + - 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: From 88fbea387bdb59d78e9b4a7617893ef685ee2784 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 14 Apr 2026 18:59:22 +0200 Subject: [PATCH 10/15] update samples --- .../kotlin/org/openapitools/api/PetApi.kt | 3 +- .../org/openapitools/api/PetApiDelegate.kt | 1 + .../org/openapitools/api/PetApiController.kt | 95 ++++++++++++++++++- .../org/openapitools/api/PetApiService.kt | 59 ++++++++++++ .../org/openapitools/api/PetApiServiceImpl.kt | 31 ++++++ .../openapitools/configuration/ValidSort.kt | 47 +++++---- .../kotlin/org/openapitools/api/PetApi.kt | 3 +- 7 files changed, 214 insertions(+), 25 deletions(-) diff --git a/samples/server/petstore/kotlin-springboot-include-http-request-context-delegate/src/main/kotlin/org/openapitools/api/PetApi.kt b/samples/server/petstore/kotlin-springboot-include-http-request-context-delegate/src/main/kotlin/org/openapitools/api/PetApi.kt index 977ec3a70961..ee67a71a0410 100644 --- a/samples/server/petstore/kotlin-springboot-include-http-request-context-delegate/src/main/kotlin/org/openapitools/api/PetApi.kt +++ b/samples/server/petstore/kotlin-springboot-include-http-request-context-delegate/src/main/kotlin/org/openapitools/api/PetApi.kt @@ -7,6 +7,7 @@ package org.openapitools.api import org.openapitools.model.ModelApiResponse import org.springframework.data.domain.Pageable +import org.springframework.data.web.PageableDefault import org.openapitools.model.Pet import io.swagger.annotations.Api import io.swagger.annotations.ApiOperation @@ -183,7 +184,7 @@ interface PetApi { produces = ["application/json"] ) fun listAllPetsPaginated(@ApiParam(hidden = true) exchange: org.springframework.web.server.ServerWebExchange, - @ApiParam(hidden = true) pageable: Pageable): ResponseEntity> { + @PageableDefault(page = 0, size = 20) @ApiParam(hidden = true) pageable: Pageable): ResponseEntity> { return getDelegate().listAllPetsPaginated(exchange, pageable) } diff --git a/samples/server/petstore/kotlin-springboot-include-http-request-context-delegate/src/main/kotlin/org/openapitools/api/PetApiDelegate.kt b/samples/server/petstore/kotlin-springboot-include-http-request-context-delegate/src/main/kotlin/org/openapitools/api/PetApiDelegate.kt index f1b877a0fbb7..ad4642030058 100644 --- a/samples/server/petstore/kotlin-springboot-include-http-request-context-delegate/src/main/kotlin/org/openapitools/api/PetApiDelegate.kt +++ b/samples/server/petstore/kotlin-springboot-include-http-request-context-delegate/src/main/kotlin/org/openapitools/api/PetApiDelegate.kt @@ -2,6 +2,7 @@ package org.openapitools.api import org.openapitools.model.ModelApiResponse import org.springframework.data.domain.Pageable +import org.springframework.data.web.PageableDefault import org.openapitools.model.Pet import org.springframework.http.HttpStatus import org.springframework.http.MediaType diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiController.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiController.kt index 3991d64cd2bb..b5330926a2c9 100644 --- a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiController.kt +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiController.kt @@ -1,8 +1,11 @@ package org.openapitools.api import org.springframework.data.domain.Pageable +import org.springframework.data.web.PageableDefault import org.openapitools.model.Pet import org.openapitools.model.PetSort +import org.springframework.data.domain.Sort +import org.springframework.data.web.SortDefault import org.openapitools.configuration.ValidSort import org.springframework.http.HttpStatus import org.springframework.http.MediaType @@ -60,19 +63,94 @@ class PetApiController(@Autowired(required = true) val service: PetApiService) { } - @ValidSort(allowedValues = ["id,asc", "id,desc", "createdAt,asc", "createdAt,desc"]) + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithAllDefaults" + value = [PATH_FIND_PETS_WITH_ALL_DEFAULTS], + produces = ["application/json"] + ) + fun findPetsWithAllDefaults(@PageableDefault(page = 0, size = 10) @SortDefault.SortDefaults(SortDefault(sort = ["name"], direction = Sort.Direction.DESC), SortDefault(sort = ["id"], direction = Sort.Direction.ASC)) pageable: Pageable): ResponseEntity> { + return ResponseEntity(service.findPetsWithAllDefaults(), HttpStatus.valueOf(200)) + } + + + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithMixedSortDefaults" + value = [PATH_FIND_PETS_WITH_MIXED_SORT_DEFAULTS], + produces = ["application/json"] + ) + fun findPetsWithMixedSortDefaults(@SortDefault.SortDefaults(SortDefault(sort = ["name"], direction = Sort.Direction.DESC), SortDefault(sort = ["id"], direction = Sort.Direction.ASC)) pageable: Pageable): ResponseEntity> { + return ResponseEntity(service.findPetsWithMixedSortDefaults(), HttpStatus.valueOf(200)) + } + + + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithPageAndSizeConstraint" + value = [PATH_FIND_PETS_WITH_PAGE_AND_SIZE_CONSTRAINT], + produces = ["application/json"] + ) + fun findPetsWithPageAndSizeConstraint(pageable: Pageable): ResponseEntity> { + return ResponseEntity(service.findPetsWithPageAndSizeConstraint(), HttpStatus.valueOf(200)) + } + + + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithPageSizeDefaultsOnly" + value = [PATH_FIND_PETS_WITH_PAGE_SIZE_DEFAULTS_ONLY], + produces = ["application/json"] + ) + fun findPetsWithPageSizeDefaultsOnly(@PageableDefault(page = 0, size = 25) pageable: Pageable): ResponseEntity> { + return ResponseEntity(service.findPetsWithPageSizeDefaultsOnly(), HttpStatus.valueOf(200)) + } + + @RequestMapping( method = [RequestMethod.GET], // "/pet/findWithRefSort" value = [PATH_FIND_PETS_WITH_REF_SORT], produces = ["application/json"] ) - fun findPetsWithRefSort(pageable: Pageable): ResponseEntity> { + fun findPetsWithRefSort(@ValidSort(allowedValues = ["id,asc", "id,desc", "createdAt,asc", "createdAt,desc"]) @PageableDefault(page = 0, size = 20) pageable: Pageable): ResponseEntity> { return ResponseEntity(service.findPetsWithRefSort(), HttpStatus.valueOf(200)) } - @ValidSort(allowedValues = ["id,asc", "id,desc", "name,asc", "name,desc"]) + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithSizeConstraint" + value = [PATH_FIND_PETS_WITH_SIZE_CONSTRAINT], + produces = ["application/json"] + ) + fun findPetsWithSizeConstraint(pageable: Pageable): ResponseEntity> { + return ResponseEntity(service.findPetsWithSizeConstraint(), HttpStatus.valueOf(200)) + } + + + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithSortDefaultAsc" + value = [PATH_FIND_PETS_WITH_SORT_DEFAULT_ASC], + produces = ["application/json"] + ) + fun findPetsWithSortDefaultAsc(@SortDefault.SortDefaults(SortDefault(sort = ["id"], direction = Sort.Direction.ASC)) pageable: Pageable): ResponseEntity> { + return ResponseEntity(service.findPetsWithSortDefaultAsc(), HttpStatus.valueOf(200)) + } + + + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithSortDefaultOnly" + value = [PATH_FIND_PETS_WITH_SORT_DEFAULT_ONLY], + produces = ["application/json"] + ) + fun findPetsWithSortDefaultOnly(@SortDefault.SortDefaults(SortDefault(sort = ["name"], direction = Sort.Direction.DESC)) pageable: Pageable): ResponseEntity> { + return ResponseEntity(service.findPetsWithSortDefaultOnly(), HttpStatus.valueOf(200)) + } + + @RequestMapping( method = [RequestMethod.GET], // "/pet/findByStatusWithSort" @@ -81,7 +159,7 @@ class PetApiController(@Autowired(required = true) val service: PetApiService) { ) fun findPetsWithSortEnum( @Valid @RequestParam(value = "status", required = false) status: kotlin.String?, - pageable: Pageable + @ValidSort(allowedValues = ["id,asc", "id,desc", "name,asc", "name,desc"]) @PageableDefault(page = 0, size = 20) pageable: Pageable ): ResponseEntity> { return ResponseEntity(service.findPetsWithSortEnum(status), HttpStatus.valueOf(200)) } @@ -93,7 +171,7 @@ class PetApiController(@Autowired(required = true) val service: PetApiService) { value = [PATH_FIND_PETS_WITHOUT_SORT_ENUM], produces = ["application/json"] ) - fun findPetsWithoutSortEnum(pageable: Pageable): ResponseEntity> { + fun findPetsWithoutSortEnum(@PageableDefault(page = 0, size = 20) pageable: Pageable): ResponseEntity> { return ResponseEntity(service.findPetsWithoutSortEnum(), HttpStatus.valueOf(200)) } @@ -101,7 +179,14 @@ class PetApiController(@Autowired(required = true) val service: PetApiService) { //for your own safety never directly reuse these path definitions in tests const val PATH_FIND_PETS_AUTO_DETECTED_WITH_SORT: String = "/pet/findAutoDetectedWithSort" const val PATH_FIND_PETS_NON_PAGINATED_WITH_SORT_ENUM: String = "/pet/findNonPaginatedWithSortEnum" + const val PATH_FIND_PETS_WITH_ALL_DEFAULTS: String = "/pet/findWithAllDefaults" + const val PATH_FIND_PETS_WITH_MIXED_SORT_DEFAULTS: String = "/pet/findWithMixedSortDefaults" + 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_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" const val PATH_FIND_PETS_WITHOUT_SORT_ENUM: String = "/pet/findWithoutSortEnum" } diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiService.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiService.kt index 19bac55816f4..7318c2b8605f 100644 --- a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiService.kt +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiService.kt @@ -1,8 +1,11 @@ package org.openapitools.api import org.springframework.data.domain.Pageable +import org.springframework.data.web.PageableDefault import org.openapitools.model.Pet import org.openapitools.model.PetSort +import org.springframework.data.domain.Sort +import org.springframework.data.web.SortDefault import org.openapitools.configuration.ValidSort interface PetApiService { @@ -28,6 +31,38 @@ interface PetApiService { */ fun findPetsNonPaginatedWithSortEnum(sort: kotlin.String?): List + /** + * GET /pet/findWithAllDefaults : Find pets — page, size, and mixed sort defaults all present + * + * @return successful operation (status code 200) + * @see PetApi#findPetsWithAllDefaults + */ + fun findPetsWithAllDefaults(): List + + /** + * GET /pet/findWithMixedSortDefaults : Find pets — multiple sort defaults with mixed directions (array sort param) + * + * @return successful operation (status code 200) + * @see PetApi#findPetsWithMixedSortDefaults + */ + fun findPetsWithMixedSortDefaults(): List + + /** + * GET /pet/findWithPageAndSizeConstraint : Find pets — both page and size have maximum constraints + * + * @return successful operation (status code 200) + * @see PetApi#findPetsWithPageAndSizeConstraint + */ + fun findPetsWithPageAndSizeConstraint(): List + + /** + * GET /pet/findWithPageSizeDefaultsOnly : Find pets — page and size defaults only, no sort default + * + * @return successful operation (status code 200) + * @see PetApi#findPetsWithPageSizeDefaultsOnly + */ + fun findPetsWithPageSizeDefaultsOnly(): List + /** * GET /pet/findWithRefSort : Find pets with x-spring-paginated and $ref sort enum * @@ -36,6 +71,30 @@ interface PetApiService { */ fun findPetsWithRefSort(): List + /** + * GET /pet/findWithSizeConstraint : Find pets — size has maximum constraint only + * + * @return successful operation (status code 200) + * @see PetApi#findPetsWithSizeConstraint + */ + fun findPetsWithSizeConstraint(): List + + /** + * GET /pet/findWithSortDefaultAsc : Find pets — sort default only (single field, no explicit direction defaults to ASC) + * + * @return successful operation (status code 200) + * @see PetApi#findPetsWithSortDefaultAsc + */ + fun findPetsWithSortDefaultAsc(): List + + /** + * GET /pet/findWithSortDefaultOnly : Find pets — sort default only (single field DESC, no page/size defaults) + * + * @return successful operation (status code 200) + * @see PetApi#findPetsWithSortDefaultOnly + */ + fun findPetsWithSortDefaultOnly(): List + /** * GET /pet/findByStatusWithSort : Find pets with explicit x-spring-paginated and inline sort enum * diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt index 3e0220ea2166..b2e851ba404f 100644 --- a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt @@ -1,8 +1,11 @@ package org.openapitools.api import org.springframework.data.domain.Pageable +import org.springframework.data.web.PageableDefault import org.openapitools.model.Pet import org.openapitools.model.PetSort +import org.springframework.data.domain.Sort +import org.springframework.data.web.SortDefault import org.openapitools.configuration.ValidSort import org.springframework.stereotype.Service @Service @@ -16,10 +19,38 @@ class PetApiServiceImpl : PetApiService { TODO("Implement me") } + override fun findPetsWithAllDefaults(): List { + TODO("Implement me") + } + + override fun findPetsWithMixedSortDefaults(): List { + TODO("Implement me") + } + + override fun findPetsWithPageAndSizeConstraint(): List { + TODO("Implement me") + } + + override fun findPetsWithPageSizeDefaultsOnly(): List { + TODO("Implement me") + } + override fun findPetsWithRefSort(): List { TODO("Implement me") } + override fun findPetsWithSizeConstraint(): List { + TODO("Implement me") + } + + override fun findPetsWithSortDefaultAsc(): List { + TODO("Implement me") + } + + override fun findPetsWithSortDefaultOnly(): List { + TODO("Implement me") + } + override fun findPetsWithSortEnum(status: kotlin.String?): List { TODO("Implement me") } diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidSort.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidSort.kt index 5282b0f1a28a..5c96f1c82814 100644 --- a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidSort.kt +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidSort.kt @@ -4,18 +4,25 @@ import jakarta.validation.Constraint import jakarta.validation.ConstraintValidator import jakarta.validation.ConstraintValidatorContext import jakarta.validation.Payload -import jakarta.validation.constraintvalidation.SupportedValidationTarget -import jakarta.validation.constraintvalidation.ValidationTarget import org.springframework.data.domain.Pageable /** - * Validates that sort properties in a [Pageable] parameter match the allowed values. + * Validates that sort properties in the annotated [Pageable] parameter match the allowed values. * - * This annotation can only be applied to methods that have a [Pageable] parameter. - * The validator checks that each sort property and direction combination in the [Pageable] - * matches one of the strings specified in [allowedValues]. + * Apply directly on a `pageable: Pageable` parameter. The validator checks that each sort + * property and direction combination in the [Pageable] matches one of the strings specified + * in [allowedValues]. * - * Expected value format: `"property,direction"` (e.g. `"id,asc"`, `"name,desc"`). + * Two formats are accepted in [allowedValues]: + * - `"property,direction"` — permits only the specific direction (e.g. `"id,asc"`, `"name,desc"`). + * Direction matching is case-insensitive: `"id,ASC"` and `"id,asc"` are treated identically. + * - `"property"` — permits any direction for that property (e.g. `"id"` matches `sort=id,asc` + * and `sort=id,desc`). Note: because Spring always normalises a bare `sort=id` to ascending + * before the validator runs, bare property names in [allowedValues] effectively allow all + * directions — the original omission of a direction cannot be detected. + * + * Both formats may be mixed freely. For example `["id", "name,desc"]` allows `id` in any + * direction but restricts `name` to descending only. * * @property allowedValues The allowed sort strings (e.g. `["id,asc", "id,desc"]`) * @property groups Validation groups (optional) @@ -25,7 +32,7 @@ import org.springframework.data.domain.Pageable @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) @Constraint(validatedBy = [SortValidator::class]) -@Target(AnnotationTarget.FUNCTION) +@Target(AnnotationTarget.VALUE_PARAMETER) annotation class ValidSort( val allowedValues: Array, val groups: Array> = [], @@ -33,26 +40,30 @@ annotation class ValidSort( val message: String = "Invalid sort column" ) -@SupportedValidationTarget(ValidationTarget.PARAMETERS) -class SortValidator : ConstraintValidator> { +class SortValidator : ConstraintValidator { private lateinit var allowedValues: Set override fun initialize(constraintAnnotation: ValidSort) { - allowedValues = constraintAnnotation.allowedValues.toSet() + allowedValues = constraintAnnotation.allowedValues.map { entry -> + DIRECTION_ASC_SUFFIX.replace(entry, ",asc") + .let { DIRECTION_DESC_SUFFIX.replace(it, ",desc") } + }.toSet() + } + + private companion object { + val DIRECTION_ASC_SUFFIX = Regex(",ASC$", RegexOption.IGNORE_CASE) + val DIRECTION_DESC_SUFFIX = Regex(",DESC$", RegexOption.IGNORE_CASE) } - override fun isValid(parameters: Array?, context: ConstraintValidatorContext): Boolean { - val pageable = parameters?.filterIsInstance()?.firstOrNull() - ?: throw IllegalStateException( - "@ValidSort can only be used on methods with a Pageable parameter. " + - "Ensure the annotated method has a parameter of type org.springframework.data.domain.Pageable." - ) + override fun isValid(pageable: Pageable?, context: ConstraintValidatorContext): Boolean { + if (pageable == null || pageable.sort.isUnsorted) return true val invalid = pageable.sort .foldIndexed(emptyMap()) { index, acc, order -> val sortValue = "${order.property},${order.direction.name.lowercase()}" - if (sortValue !in allowedValues) acc + (index to order.property) + // Accept "property,direction" (exact match) OR "property" alone (any direction allowed) + if (sortValue !in allowedValues && order.property !in allowedValues) acc + (index to order.property) else acc } .toSortedMap() diff --git a/samples/server/petstore/kotlin-springboot-x-kotlin-implements/src/main/kotlin/org/openapitools/api/PetApi.kt b/samples/server/petstore/kotlin-springboot-x-kotlin-implements/src/main/kotlin/org/openapitools/api/PetApi.kt index 5286ee01a091..46c8f24262b2 100644 --- a/samples/server/petstore/kotlin-springboot-x-kotlin-implements/src/main/kotlin/org/openapitools/api/PetApi.kt +++ b/samples/server/petstore/kotlin-springboot-x-kotlin-implements/src/main/kotlin/org/openapitools/api/PetApi.kt @@ -7,6 +7,7 @@ package org.openapitools.api import org.openapitools.model.ModelApiResponse import org.springframework.data.domain.Pageable +import org.springframework.data.web.PageableDefault import org.openapitools.model.Pet import io.swagger.annotations.Api import io.swagger.annotations.ApiOperation @@ -170,7 +171,7 @@ interface PetApi { produces = ["application/json"] ) fun listAllPetsPaginated(@ApiParam(hidden = true) request: javax.servlet.http.HttpServletRequest, - @ApiParam(hidden = true) pageable: Pageable): ResponseEntity> + @PageableDefault(page = 0, size = 20) @ApiParam(hidden = true) pageable: Pageable): ResponseEntity> @ApiOperation( From 67884e84afa984c58c626416f459fd0404090938 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 14 Apr 2026 19:06:22 +0200 Subject: [PATCH 11/15] update samples & fix test --- docs/generators/kotlin-spring.md | 1 + .../codegen/kotlin/spring/KotlinSpringServerCodegenTest.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/generators/kotlin-spring.md b/docs/generators/kotlin-spring.md index 6d054a5d4823..e1dde315ede9 100644 --- a/docs/generators/kotlin-spring.md +++ b/docs/generators/kotlin-spring.md @@ -34,6 +34,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |documentationProvider|Select the OpenAPI documentation provider.|
**none**
Do not publish an OpenAPI specification.
**source**
Publish the original input OpenAPI specification.
**springdoc**
Generate an OpenAPI 3 specification using SpringDoc.
|springdoc| |enumPropertyNaming|Naming convention for enum properties: 'camelCase', 'PascalCase', 'snake_case', 'UPPERCASE', and 'original'| |original| |exceptionHandler|generate default global exception handlers (not compatible with reactive. enabling reactive will disable exceptionHandler )| |true| +|generatePageableConstraintValidation|Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to paginated operations whose 'page' or 'size' parameter has a maximum constraint. Requires useBeanValidation=true and library=spring-boot.| |false| |generateSortValidation|Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to paginated operations whose 'sort' parameter has enum values. Requires useBeanValidation=true and library=spring-boot.| |false| |gradleBuildFile|generate a gradle build file using the Kotlin DSL| |true| |groupId|Generated artifact package's organization (i.e. maven groupId).| |org.openapitools| 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 5bd01b04236d..58ee3f167c87 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 @@ -3919,7 +3919,7 @@ public void springPaginatedNoParamsNoContext() throws Exception { // Test operation listAllPets which has no parameters except pageable File petApi = files.get("PetApi.kt"); - assertFileContains(petApi.toPath(), "fun listAllPets(@Parameter(hidden = true) pageable: Pageable)"); + assertFileContains(petApi.toPath(), "fun listAllPets(@PageableDefault(page = 0, size = 20) @Parameter(hidden = true) pageable: Pageable)"); } @Test From 767f8641ed3d8b61b5852462c77d8888cc0d0425 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 14 Apr 2026 21:33:36 +0200 Subject: [PATCH 12/15] feat: add validation for pageable and sort parameters with new annotations --- .../codegen/languages/SpringCodegen.java | 129 +++++++++ .../main/resources/JavaSpring/api.mustache | 2 +- .../JavaSpring/validPageable.mustache | 95 +++++++ .../resources/JavaSpring/validSort.mustache | 102 ++++++++ .../java/spring/SpringCodegenTest.java | 244 ++++++++++++++++++ 5 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 modules/openapi-generator/src/main/resources/JavaSpring/validPageable.mustache create mode 100644 modules/openapi-generator/src/main/resources/JavaSpring/validSort.mustache 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 e6a150c4e21a..3c3c2df8fd90 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 @@ -111,6 +111,9 @@ public class SpringCodegen extends AbstractJavaCodegen public static final String JACKSON3_PACKAGE = "tools.jackson"; public static final String JACKSON_PACKAGE = "jacksonPackage"; public static final String ADDITIONAL_NOT_NULL_ANNOTATIONS = "additionalNotNullAnnotations"; + public static final String AUTO_X_SPRING_PAGINATED = "autoXSpringPaginated"; + public static final String GENERATE_SORT_VALIDATION = "generateSortValidation"; + public static final String GENERATE_PAGEABLE_CONSTRAINT_VALIDATION = "generatePageableConstraintValidation"; @Getter public enum RequestMappingMode { @@ -186,6 +189,16 @@ public enum RequestMappingMode { @Getter @Setter protected boolean additionalNotNullAnnotations = false; @Setter boolean useHttpServiceProxyFactoryInterfacesConfigurator = false; + @Setter protected boolean autoXSpringPaginated = false; + @Setter protected boolean generateSortValidation = false; + @Setter protected boolean generatePageableConstraintValidation = false; + + // 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<>(); public SpringCodegen() { super(); @@ -338,6 +351,21 @@ public SpringCodegen() { cliOptions.add(CliOption.newBoolean(ADDITIONAL_NOT_NULL_ANNOTATIONS, "Add @NotNull to path variables (required by default) and requestBody.", additionalNotNullAnnotations)); + cliOptions.add(CliOption.newBoolean(AUTO_X_SPRING_PAGINATED, + "Automatically add x-spring-paginated to operations that have 'page', 'size', and 'sort' query parameters. " + + "When enabled, operations with all three parameters will have Pageable support automatically applied. " + + "Operations with x-spring-paginated explicitly set to false will not be auto-detected. " + + "Only applies when library=spring-boot.", + autoXSpringPaginated)); + cliOptions.add(CliOption.newBoolean(GENERATE_SORT_VALIDATION, + "Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to paginated operations " + + "whose 'sort' parameter has enum values. Requires useBeanValidation=true and library=spring-boot.", + generateSortValidation)); + cliOptions.add(CliOption.newBoolean(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, + "Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to " + + "paginated operations whose 'page' or 'size' parameter has a maximum constraint. " + + "Requires useBeanValidation=true and library=spring-boot.", + generatePageableConstraintValidation)); } @@ -547,6 +575,12 @@ public void processOpts() { convertPropertyToBooleanAndWriteBack(ADDITIONAL_NOT_NULL_ANNOTATIONS, this::setAdditionalNotNullAnnotations); + if (SPRING_BOOT.equals(library)) { + convertPropertyToBooleanAndWriteBack(AUTO_X_SPRING_PAGINATED, this::setAutoXSpringPaginated); + convertPropertyToBooleanAndWriteBack(GENERATE_SORT_VALIDATION, this::setGenerateSortValidation); + convertPropertyToBooleanAndWriteBack(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, this::setGeneratePageableConstraintValidation); + } + // override parent one importMapping.put("JsonDeserialize", (useJackson3 ? JACKSON3_PACKAGE : JACKSON2_PACKAGE) + ".databind.annotation.JsonDeserialize"); @@ -792,6 +826,33 @@ public void preprocessOpenAPI(OpenAPI openAPI) { (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "EnumConverterConfiguration.java")); } + if (SPRING_BOOT.equals(library) && generateSortValidation && useBeanValidation) { + sortValidationEnums = SpringPageableScanUtils.scanSortValidationEnums(openAPI, autoXSpringPaginated); + if (!sortValidationEnums.isEmpty()) { + importMapping.putIfAbsent("ValidSort", configPackage + ".ValidSort"); + supportingFiles.add(new SupportingFile("validSort.mustache", + (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "ValidSort.java")); + } + } + + if (SPRING_BOOT.equals(library)) { + pageableDefaultsRegistry = SpringPageableScanUtils.scanPageableDefaults(openAPI, autoXSpringPaginated); + if (!pageableDefaultsRegistry.isEmpty()) { + importMapping.putIfAbsent("PageableDefault", "org.springframework.data.web.PageableDefault"); + importMapping.putIfAbsent("SortDefault", "org.springframework.data.web.SortDefault"); + importMapping.putIfAbsent("Sort", "org.springframework.data.domain.Sort"); + } + } + + if (SPRING_BOOT.equals(library) && generatePageableConstraintValidation && useBeanValidation) { + pageableConstraintsRegistry = SpringPageableScanUtils.scanPageableConstraints(openAPI, autoXSpringPaginated); + if (!pageableConstraintsRegistry.isEmpty()) { + importMapping.putIfAbsent("ValidPageable", configPackage + ".ValidPageable"); + supportingFiles.add(new SupportingFile("validPageable.mustache", + (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "ValidPageable.java")); + } + } + /* * TODO the following logic should not need anymore in OAS 3.0 if * ("/".equals(swagger.getBasePath())) { swagger.setBasePath(""); } @@ -1114,6 +1175,24 @@ 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. + // Only for spring-boot; respect manual x-spring-paginated: false override. + if (SPRING_BOOT.equals(library) && autoXSpringPaginated) { + if (operation.getExtensions() == null || !Boolean.FALSE.equals(operation.getExtensions().get("x-spring-paginated"))) { + if (operation.getParameters() != null) { + Set paramNames = operation.getParameters().stream() + .map(io.swagger.v3.oas.models.parameters.Parameter::getName) + .collect(Collectors.toSet()); + if (paramNames.containsAll(Arrays.asList("page", "size", "sort"))) { + if (operation.getExtensions() == null) { + operation.setExtensions(new HashMap<>()); + } + operation.getExtensions().put("x-spring-paginated", Boolean.TRUE); + } + } + } + } + // add Pageable import only if x-spring-paginated explicitly used // this allows to use a custom Pageable schema without importing Spring Pageable. if (Boolean.TRUE.equals(operation.getExtensions().get("x-spring-paginated"))) { @@ -1142,6 +1221,56 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation // #8315 Remove matching Spring Data Web default query params if 'x-spring-paginated' with Pageable is used codegenOperation.queryParams.removeIf(param -> defaultPageableQueryParams.contains(param.baseName)); codegenOperation.allParams.removeIf(param -> param.isQueryParam && defaultPageableQueryParams.contains(param.baseName)); + + // Build pageable parameter annotations (@ValidPageable, @ValidSort, @PageableDefault, @SortDefault.SortDefaults) + List pageableAnnotations = new ArrayList<>(); + + if (generatePageableConstraintValidation && useBeanValidation && pageableConstraintsRegistry.containsKey(codegenOperation.operationId)) { + SpringPageableScanUtils.PageableConstraintsData constraints = pageableConstraintsRegistry.get(codegenOperation.operationId); + List attrs = new ArrayList<>(); + if (constraints.maxSize >= 0) attrs.add("maxSize = " + constraints.maxSize); + if (constraints.maxPage >= 0) attrs.add("maxPage = " + constraints.maxPage); + pageableAnnotations.add("@ValidPageable(" + String.join(", ", attrs) + ")"); + codegenOperation.imports.add("ValidPageable"); + } + + if (generateSortValidation && useBeanValidation && sortValidationEnums.containsKey(codegenOperation.operationId)) { + List allowedSortValues = sortValidationEnums.get(codegenOperation.operationId); + // Java annotation arrays use {} syntax + String allowedValuesStr = allowedSortValues.stream() + .map(v -> "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\"") + .collect(Collectors.joining(", ")); + pageableAnnotations.add("@ValidSort(allowedValues = {" + allowedValuesStr + "})"); + codegenOperation.imports.add("ValidSort"); + } + + if (pageableDefaultsRegistry.containsKey(codegenOperation.operationId)) { + SpringPageableScanUtils.PageableDefaultsData defaults = pageableDefaultsRegistry.get(codegenOperation.operationId); + if (defaults.page != null || defaults.size != null) { + List attrs = new ArrayList<>(); + if (defaults.page != null) attrs.add("page = " + defaults.page); + if (defaults.size != null) attrs.add("size = " + defaults.size); + pageableAnnotations.add("@PageableDefault(" + String.join(", ", attrs) + ")"); + codegenOperation.imports.add("PageableDefault"); + } + if (!defaults.sortDefaults.isEmpty()) { + // Java annotation arrays use @SortDefault(...) with {} for the sort field array + List sortEntries = defaults.sortDefaults.stream() + .map(sf -> "@SortDefault(sort = {\"" + sf.field + "\"}, direction = Sort.Direction." + sf.direction + ")") + .collect(Collectors.toList()); + if (sortEntries.size() == 1) { + pageableAnnotations.add("@SortDefault.SortDefaults(" + sortEntries.get(0) + ")"); + } else { + 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); + } } if (codegenOperation.vendorExtensions.containsKey("x-spring-provide-args") && !provideArgsClassSet.isEmpty()) { codegenOperation.imports.addAll(provideArgsClassSet); diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/api.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/api.mustache index 2f05bbace9ca..5ac4a949ca1d 100644 --- a/modules/openapi-generator/src/main/resources/JavaSpring/api.mustache +++ b/modules/openapi-generator/src/main/resources/JavaSpring/api.mustache @@ -279,7 +279,7 @@ public interface {{classname}} { {{#allParams}}{{>queryParams}}{{>pathParams}}{{>headerParams}}{{>bodyParams}}{{>formParams}}{{>cookieParams}}{{^-last}}, {{/-last}}{{/allParams}}{{#includeHttpRequestContext}}{{#hasParams}}, {{/hasParams}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true){{/swagger2AnnotationLibrary}} final {{#reactive}}ServerWebExchange exchange{{/reactive}}{{^reactive}}HttpServletRequest servletRequest{{/reactive}}{{/includeHttpRequestContext}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, - {{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}},{{/includeHttpRequestContext}}{{/hasParams}}{{#springDocDocumentationProvider}}@ParameterObject{{/springDocDocumentationProvider}} final Pageable pageable{{/vendorExtensions.x-spring-paginated}}{{#vendorExtensions.x-spring-provide-args}}{{#hasParams}}, + {{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}},{{/includeHttpRequestContext}}{{/hasParams}}{{#vendorExtensions.x-pageable-extra-annotation}}{{{.}}} {{/vendorExtensions.x-pageable-extra-annotation}}{{#springDocDocumentationProvider}}@ParameterObject{{/springDocDocumentationProvider}} final Pageable pageable{{/vendorExtensions.x-spring-paginated}}{{#vendorExtensions.x-spring-provide-args}}{{#hasParams}}, {{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}},{{/includeHttpRequestContext}}{{/hasParams}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true){{/swagger2AnnotationLibrary}} {{{.}}}{{^hasParams}}{{^-last}}{{^reactive}},{{/reactive}} {{/-last}}{{/hasParams}}{{/vendorExtensions.x-spring-provide-args}} ){{#unhandledException}} throws Exception{{/unhandledException}}{{^jdk8-default-interface}};{{/jdk8-default-interface}}{{#jdk8-default-interface}} { diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/validPageable.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/validPageable.mustache new file mode 100644 index 000000000000..2bf169d51fb4 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/JavaSpring/validPageable.mustache @@ -0,0 +1,95 @@ +package {{configPackage}}; + +import {{javaxPackage}}.validation.Constraint; +import {{javaxPackage}}.validation.ConstraintValidator; +import {{javaxPackage}}.validation.ConstraintValidatorContext; +import {{javaxPackage}}.validation.Payload; +import org.springframework.data.domain.Pageable; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +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. + * + *

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

Use {@link #NO_LIMIT} (= {@code -1}, the default) to leave an attribute unconstrained. + * + *

Constraining {@link #maxPage()} is useful to prevent deep-pagination attacks, where a large + * page offset (e.g. {@code ?page=100000&size=20}) causes an expensive {@code OFFSET} query on the + * database. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = {ValidPageable.PageableConstraintValidator.class}) +@Target({ElementType.PARAMETER}) +public @interface ValidPageable { + + /** Sentinel value meaning no limit is applied. */ + int NO_LIMIT = -1; + + /** Maximum allowed page size, or {@link #NO_LIMIT} if unconstrained. */ + int maxSize() default NO_LIMIT; + + /** Maximum allowed page number (0-based), or {@link #NO_LIMIT} if unconstrained. */ + int maxPage() default NO_LIMIT; + + Class[] groups() default {}; + + Class[] payload() default {}; + + String message() default "Invalid page request"; + + class PageableConstraintValidator implements ConstraintValidator { + + private int maxSize = NO_LIMIT; + private int maxPage = NO_LIMIT; + + @Override + public void initialize(ValidPageable constraintAnnotation) { + maxSize = constraintAnnotation.maxSize(); + maxPage = constraintAnnotation.maxPage(); + } + + @Override + public boolean isValid(Pageable pageable, ConstraintValidatorContext context) { + if (pageable == null) { + return true; + } + + boolean valid = true; + context.disableDefaultConstraintViolation(); + + if (maxSize >= 0 && pageable.getPageSize() > maxSize) { + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate() + + ": page size " + pageable.getPageSize() + + " exceeds maximum " + maxSize) + .addPropertyNode("size") + .addConstraintViolation(); + valid = false; + } + + if (maxPage >= 0 && pageable.getPageNumber() > maxPage) { + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate() + + ": page number " + pageable.getPageNumber() + + " exceeds maximum " + maxPage) + .addPropertyNode("page") + .addConstraintViolation(); + valid = false; + } + + return valid; + } + } +} diff --git a/modules/openapi-generator/src/main/resources/JavaSpring/validSort.mustache b/modules/openapi-generator/src/main/resources/JavaSpring/validSort.mustache new file mode 100644 index 000000000000..6b47813dd477 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/JavaSpring/validSort.mustache @@ -0,0 +1,102 @@ +package {{configPackage}}; + +import {{javaxPackage}}.validation.Constraint; +import {{javaxPackage}}.validation.ConstraintValidator; +import {{javaxPackage}}.validation.ConstraintValidatorContext; +import {{javaxPackage}}.validation.Payload; +import org.springframework.data.domain.Pageable; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * Validates that sort properties in the annotated {@link Pageable} parameter match the allowed values. + * + *

Apply directly on a {@code Pageable} parameter. The validator checks that each sort + * property and direction combination in the {@link Pageable} matches one of the strings specified + * in {@link #allowedValues()}. + * + *

Two formats are accepted in {@link #allowedValues()}: + *

    + *
  • {@code "property,direction"} — permits only the specific direction (e.g. {@code "id,asc"}, + * {@code "name,desc"}). Direction matching is case-insensitive. + *
  • {@code "property"} — permits any direction for that property (e.g. {@code "id"} matches + * {@code sort=id,asc} and {@code sort=id,desc}). Note: because Spring always normalises a + * bare {@code sort=id} to ascending before the validator runs, bare property names in + * {@link #allowedValues()} effectively allow all directions. + *
+ * + *

Both formats may be mixed freely. For example {@code {"id", "name,desc"}} allows {@code id} + * in any direction but restricts {@code name} to descending only. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = {ValidSort.SortValidator.class}) +@Target({ElementType.PARAMETER}) +public @interface ValidSort { + + /** The allowed sort strings (e.g. {@code {"id,asc", "id,desc"}}). */ + String[] allowedValues(); + + Class[] groups() default {}; + + Class[] payload() default {}; + + String message() default "Invalid sort column"; + + class SortValidator implements ConstraintValidator { + + private Set allowedValues; + + @Override + public void initialize(ValidSort constraintAnnotation) { + allowedValues = Arrays.stream(constraintAnnotation.allowedValues()) + .map(entry -> entry + .replaceAll("(?i),ASC$", ",asc") + .replaceAll("(?i),DESC$", ",desc")) + .collect(Collectors.toSet()); + } + + @Override + public boolean isValid(Pageable pageable, ConstraintValidatorContext context) { + if (pageable == null || pageable.getSort().isUnsorted()) { + return true; + } + + Map invalid = new TreeMap<>(); + int[] index = {0}; + pageable.getSort().forEach(order -> { + String sortValue = order.getProperty() + "," + order.getDirection().name().toLowerCase(java.util.Locale.ROOT); + // Accept "property,direction" (exact match) OR "property" alone (any direction allowed) + if (!allowedValues.contains(sortValue) && !allowedValues.contains(order.getProperty())) { + invalid.put(index[0], order.getProperty()); + } + index[0]++; + }); + + if (!invalid.isEmpty()) { + context.disableDefaultConstraintViolation(); + invalid.forEach((i, property) -> + context.buildConstraintViolationWithTemplate( + context.getDefaultConstraintMessageTemplate() + " [" + property + "]") + .addPropertyNode("sort") + .addPropertyNode("property") + .inIterable() + .atIndex(i) + .addConstraintViolation()); + } + + return invalid.isEmpty(); + } + } +} 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 96444d9aee77..4884eeba9a28 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 @@ -6664,4 +6664,248 @@ public void testJspecify(String library, int springBootVersion, String fooApiFil JavaFileAssert.assertThat(files.get("model/package-info.java")) .fileContains("@org.jspecify.annotations.NullMarked"); } + + // ------------------------------------------------------------------------- + // autoXSpringPaginated tests + // ------------------------------------------------------------------------- + + @Test + public void autoXSpringPaginatedDetectsAllThreeParams() 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.AUTO_X_SPRING_PAGINATED, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-auto-paginated.yaml", SPRING_BOOT, props); + + // findPetsWithAutoDetect has page+size+sort → Pageable should be injected + JavaFileAssert.assertThat(files.get("PetApi.java")) + .assertMethod("findPetsWithAutoDetect") + .assertParameter("pageable").hasType("Pageable"); + } + + @Test + public void autoXSpringPaginatedManualFalseTakesPrecedence() 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.AUTO_X_SPRING_PAGINATED, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-auto-paginated.yaml", SPRING_BOOT, props); + + // findPetsManualFalse has x-spring-paginated: false → Pageable must NOT be injected + JavaFileAssert.assertThat(files.get("PetApi.java")) + .assertMethod("findPetsManualFalse") + .doesNotHaveParameter("pageable"); + } + + @Test + public void autoXSpringPaginatedCaseSensitiveMatching() 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.AUTO_X_SPRING_PAGINATED, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-auto-paginated.yaml", SPRING_BOOT, props); + + // findPetsCaseSensitive uses Page/Size/Sort (capital) → must NOT auto-detect + JavaFileAssert.assertThat(files.get("PetApi.java")) + .assertMethod("findPetsCaseSensitive") + .doesNotHaveParameter("pageable"); + } + + // ------------------------------------------------------------------------- + // generateSortValidation tests + // ------------------------------------------------------------------------- + + @Test + public void generateSortValidationAddsAnnotationAndGeneratesFile() 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_SORT_VALIDATION, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-sort-validation.yaml", SPRING_BOOT, props); + + // ValidSort.java must be generated + assertThat(files).containsKey("ValidSort.java"); + + // findPetsWithSortEnum has explicit x-spring-paginated + sort enum → @ValidSort applied with all 4 values + JavaFileAssert.assertThat(files.get("PetApi.java")) + .fileContains("@ValidSort(allowedValues = {") + .fileContains("\"id,asc\"") + .fileContains("\"id,desc\"") + .fileContains("\"name,asc\"") + .fileContains("\"name,desc\""); + } + + @Test + public void generateSortValidationUsesJavaArraySyntax() 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_SORT_VALIDATION, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-sort-validation.yaml", SPRING_BOOT, props); + + // The generated API file must use Java {} array syntax (not Kotlin []) + JavaFileAssert.assertThat(files.get("PetApi.java")) + .fileContains("@ValidSort(allowedValues = {"); + } + + @Test + public void generateSortValidationWithAutoDetect() 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.AUTO_X_SPRING_PAGINATED, "true"); + props.put(SpringCodegen.GENERATE_SORT_VALIDATION, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-sort-validation.yaml", SPRING_BOOT, props); + + // findPetsAutoDetectedWithSort: auto-detected + sort enum → ValidSort applied with Java {} syntax + JavaFileAssert.assertThat(files.get("PetApi.java")) + .fileContains("@ValidSort(allowedValues = {") + .fileContains("\"id,asc\"") + .fileContains("\"id,desc\""); + } + + @Test + public void generateSortValidationNotAppliedWhenNoSortEnum() 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_SORT_VALIDATION, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-sort-validation.yaml", SPRING_BOOT, props); + + // findPetsWithoutSortEnum: paginated but sort has no enum → no @ValidSort + JavaFileAssert.assertThat(files.get("PetApi.java")) + .assertMethod("findPetsWithoutSortEnum") + .assertParameter("pageable") + .assertParameterAnnotations() + .doesNotContainWithName("ValidSort"); + } + + // ------------------------------------------------------------------------- + // generatePageableConstraintValidation tests + // ------------------------------------------------------------------------- + + @Test + public void generatePageableConstraintValidationAddsAnnotationAndGeneratesFile() 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); + + // ValidPageable.java must be generated + assertThat(files).containsKey("ValidPageable.java"); + + // findPetsWithSizeConstraint: size maximum=100 → @ValidPageable(maxSize = 100) + JavaFileAssert.assertThat(files.get("PetApi.java")) + .assertMethod("findPetsWithSizeConstraint") + .assertParameter("pageable") + .assertParameterAnnotations() + .containsWithNameAndAttributes("ValidPageable", Map.of("maxSize", "100")); + } + + @Test + public void generatePageableConstraintValidationWithBothConstraints() 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); + + // findPetsWithPageAndSizeConstraint: page maximum=999, size maximum=50 + JavaFileAssert.assertThat(files.get("PetApi.java")) + .assertMethod("findPetsWithPageAndSizeConstraint") + .assertParameter("pageable") + .assertParameterAnnotations() + .containsWithNameAndAttributes("ValidPageable", Map.of("maxSize", "50", "maxPage", "999")); + } + + // ------------------------------------------------------------------------- + // @PageableDefault / @SortDefault tests + // ------------------------------------------------------------------------- + + @Test + public void pageableDefaultAnnotationApplied() 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); + + // findPetsWithPageSizeDefaultsOnly: page=0, size=25 → @PageableDefault(page = 0, size = 25) + JavaFileAssert.assertThat(files.get("PetApi.java")) + .assertMethod("findPetsWithPageSizeDefaultsOnly") + .assertParameter("pageable") + .assertParameterAnnotations() + .containsWithNameAndAttributes("PageableDefault", Map.of("page", "0", "size", "25")); + } + + @Test + public void sortDefaultAnnotationApplied() 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); + + // findPetsWithSortDefaultOnly: sort default "name,desc" → @SortDefault.SortDefaults generated + JavaFileAssert.assertThat(files.get("PetApi.java")) + .fileContains("@SortDefault.SortDefaults("); + } + + @Test + public void sortDefaultAndPageableDefaultBothApplied() 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); + + // findPetsWithAllDefaults: page=0, size=10, sort=["name,desc","id,asc"] + // → @PageableDefault + @SortDefault.SortDefaults both present + JavaFileAssert.assertThat(files.get("PetApi.java")) + .fileContains("@PageableDefault(page = 0, size = 10)") + .fileContains("@SortDefault.SortDefaults("); + } } From 32b6c4a9af57be029fac207cdb0eed72f26c184d Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 14 Apr 2026 23:12:01 +0200 Subject: [PATCH 13/15] simplify implementation --- docs/generators/java-camel.md | 3 +++ docs/generators/spring.md | 3 +++ .../org/openapitools/codegen/languages/SpringCodegen.java | 6 +----- .../openapitools/codegen/java/spring/SpringCodegenTest.java | 4 ++-- .../src/main/java/org/openapitools/api/PetApi.java | 5 ++++- .../src/main/java/org/openapitools/api/PetApiDelegate.java | 3 +++ .../src/main/java/org/openapitools/api/PetApi.java | 5 ++++- .../src/main/java/org/openapitools/api/PetApiDelegate.java | 3 +++ .../src/main/java/org/openapitools/api/PetApi.java | 5 ++++- .../src/main/java/org/openapitools/api/PetApi.java | 5 ++++- 10 files changed, 31 insertions(+), 11 deletions(-) diff --git a/docs/generators/java-camel.md b/docs/generators/java-camel.md index 69aa6401cd81..abbc551fc9e4 100644 --- a/docs/generators/java-camel.md +++ b/docs/generators/java-camel.md @@ -31,6 +31,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |artifactUrl|artifact URL in generated pom.xml| |https://github.com/openapitools/openapi-generator| |artifactVersion|artifact version in generated pom.xml. This also becomes part of the generated library's filename. If not provided, uses the version from the OpenAPI specification file. If that's also not present, uses the default value of the artifactVersion option.| |1.0.0| |async|use async Callable controllers| |false| +|autoXSpringPaginated|Automatically add x-spring-paginated to operations that have 'page', 'size', and 'sort' query parameters. When enabled, operations with all three parameters will have Pageable support automatically applied. Operations with x-spring-paginated explicitly set to false will not be auto-detected. Only applies when library=spring-boot.| |false| |basePackage|base package (invokerPackage) for generated code| |org.openapitools| |bigDecimalAsString|Treat BigDecimal values as Strings to avoid precision loss.| |false| |booleanGetterPrefix|Set booleanGetterPrefix| |get| @@ -62,6 +63,8 @@ These options may be applied as additional-properties (cli) or configOptions (pl |generateBuilders|Whether to generate builders for models| |false| |generateConstructorWithAllArgs|whether to generate a constructor for all arguments| |false| |generateGenericResponseEntity|Use a generic type for the `ResponseEntity` wrapping return values of generated API methods. If enabled, method are generated with return type ResponseEntity<?>| |false| +|generatePageableConstraintValidation|Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to paginated operations whose 'page' or 'size' parameter has a maximum constraint. Requires useBeanValidation=true and library=spring-boot.| |false| +|generateSortValidation|Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to paginated operations whose 'sort' parameter has enum values. Requires useBeanValidation=true and library=spring-boot.| |false| |generatedConstructorWithRequiredArgs|Whether to generate constructors with required args for models| |true| |groupId|groupId in generated pom.xml| |org.openapitools| |hateoas|Use Spring HATEOAS library to allow adding HATEOAS links| |false| diff --git a/docs/generators/spring.md b/docs/generators/spring.md index ccbb5e3443c7..a03cfa5a6889 100644 --- a/docs/generators/spring.md +++ b/docs/generators/spring.md @@ -31,6 +31,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |artifactUrl|artifact URL in generated pom.xml| |https://github.com/openapitools/openapi-generator| |artifactVersion|artifact version in generated pom.xml. This also becomes part of the generated library's filename. If not provided, uses the version from the OpenAPI specification file. If that's also not present, uses the default value of the artifactVersion option.| |1.0.0| |async|use async Callable controllers| |false| +|autoXSpringPaginated|Automatically add x-spring-paginated to operations that have 'page', 'size', and 'sort' query parameters. When enabled, operations with all three parameters will have Pageable support automatically applied. Operations with x-spring-paginated explicitly set to false will not be auto-detected. Only applies when library=spring-boot.| |false| |basePackage|base package (invokerPackage) for generated code| |org.openapitools| |bigDecimalAsString|Treat BigDecimal values as Strings to avoid precision loss.| |false| |booleanGetterPrefix|Set booleanGetterPrefix| |get| @@ -55,6 +56,8 @@ These options may be applied as additional-properties (cli) or configOptions (pl |generateBuilders|Whether to generate builders for models| |false| |generateConstructorWithAllArgs|whether to generate a constructor for all arguments| |false| |generateGenericResponseEntity|Use a generic type for the `ResponseEntity` wrapping return values of generated API methods. If enabled, method are generated with return type ResponseEntity<?>| |false| +|generatePageableConstraintValidation|Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to paginated operations whose 'page' or 'size' parameter has a maximum constraint. Requires useBeanValidation=true and library=spring-boot.| |false| +|generateSortValidation|Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to paginated operations whose 'sort' parameter has enum values. Requires useBeanValidation=true and library=spring-boot.| |false| |generatedConstructorWithRequiredArgs|Whether to generate constructors with required args for models| |true| |groupId|groupId in generated pom.xml| |org.openapitools| |hateoas|Use Spring HATEOAS library to allow adding HATEOAS links| |false| diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java index 3c3c2df8fd90..ccd6bd7259ac 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 @@ -1258,11 +1258,7 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation List sortEntries = defaults.sortDefaults.stream() .map(sf -> "@SortDefault(sort = {\"" + sf.field + "\"}, direction = Sort.Direction." + sf.direction + ")") .collect(Collectors.toList()); - if (sortEntries.size() == 1) { - pageableAnnotations.add("@SortDefault.SortDefaults(" + sortEntries.get(0) + ")"); - } else { - pageableAnnotations.add("@SortDefault.SortDefaults({" + String.join(", ", sortEntries) + "})"); - } + pageableAnnotations.add("@SortDefault.SortDefaults({" + String.join(", ", sortEntries) + "})"); codegenOperation.imports.add("SortDefault"); codegenOperation.imports.add("Sort"); } 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 4884eeba9a28..1d312acf3646 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 @@ -6888,7 +6888,7 @@ public void sortDefaultAnnotationApplied() throws IOException { // findPetsWithSortDefaultOnly: sort default "name,desc" → @SortDefault.SortDefaults generated JavaFileAssert.assertThat(files.get("PetApi.java")) - .fileContains("@SortDefault.SortDefaults("); + .fileContains("@SortDefault.SortDefaults({@SortDefault(sort = {\"name\"}, direction = Sort.Direction.DESC)})"); } @Test @@ -6906,6 +6906,6 @@ public void sortDefaultAndPageableDefaultBothApplied() throws IOException { // → @PageableDefault + @SortDefault.SortDefaults both present JavaFileAssert.assertThat(files.get("PetApi.java")) .fileContains("@PageableDefault(page = 0, size = 10)") - .fileContains("@SortDefault.SortDefaults("); + .fileContains("@SortDefault.SortDefaults({@SortDefault(sort = {\"name\"}, direction = Sort.Direction.DESC), @SortDefault(sort = {\"id\"}, direction = Sort.Direction.ASC)})"); } } diff --git a/samples/server/petstore/springboot-spring-pageable-delegatePattern-without-j8/src/main/java/org/openapitools/api/PetApi.java b/samples/server/petstore/springboot-spring-pageable-delegatePattern-without-j8/src/main/java/org/openapitools/api/PetApi.java index 52dec7c6802c..7164868c12ea 100644 --- a/samples/server/petstore/springboot-spring-pageable-delegatePattern-without-j8/src/main/java/org/openapitools/api/PetApi.java +++ b/samples/server/petstore/springboot-spring-pageable-delegatePattern-without-j8/src/main/java/org/openapitools/api/PetApi.java @@ -8,8 +8,11 @@ import org.openapitools.model.ModelApiResponse; import org.springframework.lang.Nullable; import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; import org.springdoc.core.annotations.ParameterObject; import org.openapitools.model.Pet; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.SortDefault; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -182,7 +185,7 @@ default ResponseEntity> findPetsByStatus( default ResponseEntity> findPetsByTags( @NotNull @Parameter(name = "tags", description = "Tags to filter by", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "tags", required = true) List tags, @Parameter(name = "size", description = "A test HeaderParam for issue #8315 - must NOT be removed when x-spring-paginated:true is used.", in = ParameterIn.HEADER) @RequestHeader(value = "size", required = false) @Nullable String size, - @ParameterObject final Pageable pageable + @PageableDefault(page = 0, size = 20) @SortDefault.SortDefaults({@SortDefault(sort = {"id"}, direction = Sort.Direction.ASC)}) @ParameterObject final Pageable pageable ) { return getDelegate().findPetsByTags(tags, size, pageable); } diff --git a/samples/server/petstore/springboot-spring-pageable-delegatePattern-without-j8/src/main/java/org/openapitools/api/PetApiDelegate.java b/samples/server/petstore/springboot-spring-pageable-delegatePattern-without-j8/src/main/java/org/openapitools/api/PetApiDelegate.java index ab9374eeb0ed..fc1b51ec13fc 100644 --- a/samples/server/petstore/springboot-spring-pageable-delegatePattern-without-j8/src/main/java/org/openapitools/api/PetApiDelegate.java +++ b/samples/server/petstore/springboot-spring-pageable-delegatePattern-without-j8/src/main/java/org/openapitools/api/PetApiDelegate.java @@ -3,8 +3,11 @@ import org.openapitools.model.ModelApiResponse; import org.springframework.lang.Nullable; import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; import org.springdoc.core.annotations.ParameterObject; import org.openapitools.model.Pet; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.SortDefault; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; diff --git a/samples/server/petstore/springboot-spring-pageable-delegatePattern/src/main/java/org/openapitools/api/PetApi.java b/samples/server/petstore/springboot-spring-pageable-delegatePattern/src/main/java/org/openapitools/api/PetApi.java index 52dec7c6802c..7164868c12ea 100644 --- a/samples/server/petstore/springboot-spring-pageable-delegatePattern/src/main/java/org/openapitools/api/PetApi.java +++ b/samples/server/petstore/springboot-spring-pageable-delegatePattern/src/main/java/org/openapitools/api/PetApi.java @@ -8,8 +8,11 @@ import org.openapitools.model.ModelApiResponse; import org.springframework.lang.Nullable; import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; import org.springdoc.core.annotations.ParameterObject; import org.openapitools.model.Pet; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.SortDefault; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -182,7 +185,7 @@ default ResponseEntity> findPetsByStatus( default ResponseEntity> findPetsByTags( @NotNull @Parameter(name = "tags", description = "Tags to filter by", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "tags", required = true) List tags, @Parameter(name = "size", description = "A test HeaderParam for issue #8315 - must NOT be removed when x-spring-paginated:true is used.", in = ParameterIn.HEADER) @RequestHeader(value = "size", required = false) @Nullable String size, - @ParameterObject final Pageable pageable + @PageableDefault(page = 0, size = 20) @SortDefault.SortDefaults({@SortDefault(sort = {"id"}, direction = Sort.Direction.ASC)}) @ParameterObject final Pageable pageable ) { return getDelegate().findPetsByTags(tags, size, pageable); } diff --git a/samples/server/petstore/springboot-spring-pageable-delegatePattern/src/main/java/org/openapitools/api/PetApiDelegate.java b/samples/server/petstore/springboot-spring-pageable-delegatePattern/src/main/java/org/openapitools/api/PetApiDelegate.java index ab9374eeb0ed..fc1b51ec13fc 100644 --- a/samples/server/petstore/springboot-spring-pageable-delegatePattern/src/main/java/org/openapitools/api/PetApiDelegate.java +++ b/samples/server/petstore/springboot-spring-pageable-delegatePattern/src/main/java/org/openapitools/api/PetApiDelegate.java @@ -3,8 +3,11 @@ import org.openapitools.model.ModelApiResponse; import org.springframework.lang.Nullable; import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; import org.springdoc.core.annotations.ParameterObject; import org.openapitools.model.Pet; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.SortDefault; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; diff --git a/samples/server/petstore/springboot-spring-pageable-without-j8/src/main/java/org/openapitools/api/PetApi.java b/samples/server/petstore/springboot-spring-pageable-without-j8/src/main/java/org/openapitools/api/PetApi.java index 953ff2a55663..65ca60f6970b 100644 --- a/samples/server/petstore/springboot-spring-pageable-without-j8/src/main/java/org/openapitools/api/PetApi.java +++ b/samples/server/petstore/springboot-spring-pageable-without-j8/src/main/java/org/openapitools/api/PetApi.java @@ -8,8 +8,11 @@ import org.openapitools.model.ModelApiResponse; import org.springframework.lang.Nullable; import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; import org.springdoc.core.annotations.ParameterObject; import org.openapitools.model.Pet; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.SortDefault; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -203,7 +206,7 @@ default ResponseEntity> findPetsByStatus( default ResponseEntity> findPetsByTags( @NotNull @Parameter(name = "tags", description = "Tags to filter by", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "tags", required = true) List tags, @Parameter(name = "size", description = "A test HeaderParam for issue #8315 - must NOT be removed when x-spring-paginated:true is used.", in = ParameterIn.HEADER) @RequestHeader(value = "size", required = false) @Nullable String size, - @ParameterObject final Pageable pageable + @PageableDefault(page = 0, size = 20) @SortDefault.SortDefaults({@SortDefault(sort = {"id"}, direction = Sort.Direction.ASC)}) @ParameterObject final Pageable pageable ) { getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { diff --git a/samples/server/petstore/springboot-spring-pageable/src/main/java/org/openapitools/api/PetApi.java b/samples/server/petstore/springboot-spring-pageable/src/main/java/org/openapitools/api/PetApi.java index 953ff2a55663..65ca60f6970b 100644 --- a/samples/server/petstore/springboot-spring-pageable/src/main/java/org/openapitools/api/PetApi.java +++ b/samples/server/petstore/springboot-spring-pageable/src/main/java/org/openapitools/api/PetApi.java @@ -8,8 +8,11 @@ import org.openapitools.model.ModelApiResponse; import org.springframework.lang.Nullable; import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; import org.springdoc.core.annotations.ParameterObject; import org.openapitools.model.Pet; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.SortDefault; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -203,7 +206,7 @@ default ResponseEntity> findPetsByStatus( default ResponseEntity> findPetsByTags( @NotNull @Parameter(name = "tags", description = "Tags to filter by", required = true, in = ParameterIn.QUERY) @Valid @RequestParam(value = "tags", required = true) List tags, @Parameter(name = "size", description = "A test HeaderParam for issue #8315 - must NOT be removed when x-spring-paginated:true is used.", in = ParameterIn.HEADER) @RequestHeader(value = "size", required = false) @Nullable String size, - @ParameterObject final Pageable pageable + @PageableDefault(page = 0, size = 20) @SortDefault.SortDefaults({@SortDefault(sort = {"id"}, direction = Sort.Direction.ASC)}) @ParameterObject final Pageable pageable ) { getRequest().ifPresent(request -> { for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { From 9728c9ac3df39df7a65ad303f399d399d6fe801c Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Tue, 14 Apr 2026 23:20:54 +0200 Subject: [PATCH 14/15] improve doc description --- docs/generators/java-camel.md | 4 ++-- docs/generators/kotlin-spring.md | 4 ++-- docs/generators/spring.md | 4 ++-- .../codegen/languages/KotlinSpringServerCodegen.java | 4 ++-- .../openapitools/codegen/languages/SpringCodegen.java | 9 ++++++--- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/generators/java-camel.md b/docs/generators/java-camel.md index abbc551fc9e4..951973049540 100644 --- a/docs/generators/java-camel.md +++ b/docs/generators/java-camel.md @@ -63,8 +63,8 @@ These options may be applied as additional-properties (cli) or configOptions (pl |generateBuilders|Whether to generate builders for models| |false| |generateConstructorWithAllArgs|whether to generate a constructor for all arguments| |false| |generateGenericResponseEntity|Use a generic type for the `ResponseEntity` wrapping return values of generated API methods. If enabled, method are generated with return type ResponseEntity<?>| |false| -|generatePageableConstraintValidation|Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to paginated operations whose 'page' or 'size' parameter has a maximum constraint. Requires useBeanValidation=true and library=spring-boot.| |false| -|generateSortValidation|Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to paginated operations whose 'sort' parameter has enum values. Requires useBeanValidation=true and library=spring-boot.| |false| +|generatePageableConstraintValidation|Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to the injected Pageable parameter of operations whose 'page' or 'size' parameter specifies a maximum constraint. The annotation enforces those constraints on the Pageable object that replaces the individual page/size query parameters. Requires useBeanValidation=true and library=spring-boot.| |false| +|generateSortValidation|Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to the injected Pageable parameter of operations whose 'sort' parameter has enum values. The annotation validates that sort values in the Pageable object match the allowed enum values from the spec. Requires useBeanValidation=true and library=spring-boot.| |false| |generatedConstructorWithRequiredArgs|Whether to generate constructors with required args for models| |true| |groupId|groupId in generated pom.xml| |org.openapitools| |hateoas|Use Spring HATEOAS library to allow adding HATEOAS links| |false| diff --git a/docs/generators/kotlin-spring.md b/docs/generators/kotlin-spring.md index e1dde315ede9..90828667f3a5 100644 --- a/docs/generators/kotlin-spring.md +++ b/docs/generators/kotlin-spring.md @@ -34,8 +34,8 @@ These options may be applied as additional-properties (cli) or configOptions (pl |documentationProvider|Select the OpenAPI documentation provider.|

**none**
Do not publish an OpenAPI specification.
**source**
Publish the original input OpenAPI specification.
**springdoc**
Generate an OpenAPI 3 specification using SpringDoc.
|springdoc| |enumPropertyNaming|Naming convention for enum properties: 'camelCase', 'PascalCase', 'snake_case', 'UPPERCASE', and 'original'| |original| |exceptionHandler|generate default global exception handlers (not compatible with reactive. enabling reactive will disable exceptionHandler )| |true| -|generatePageableConstraintValidation|Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to paginated operations whose 'page' or 'size' parameter has a maximum constraint. Requires useBeanValidation=true and library=spring-boot.| |false| -|generateSortValidation|Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to paginated operations whose 'sort' parameter has enum values. Requires useBeanValidation=true and library=spring-boot.| |false| +|generatePageableConstraintValidation|Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to the injected Pageable parameter of operations whose 'page' or 'size' parameter specifies a maximum constraint. The annotation enforces those constraints on the Pageable object that replaces the individual page/size query parameters. Requires useBeanValidation=true and library=spring-boot.| |false| +|generateSortValidation|Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to the injected Pageable parameter of operations whose 'sort' parameter has enum values. The annotation validates that sort values in the Pageable object match the allowed enum values from the spec. Requires useBeanValidation=true and library=spring-boot.| |false| |gradleBuildFile|generate a gradle build file using the Kotlin DSL| |true| |groupId|Generated artifact package's organization (i.e. maven groupId).| |org.openapitools| |implicitHeaders|Skip header parameters in the generated API methods.| |false| diff --git a/docs/generators/spring.md b/docs/generators/spring.md index a03cfa5a6889..be715a2763db 100644 --- a/docs/generators/spring.md +++ b/docs/generators/spring.md @@ -56,8 +56,8 @@ These options may be applied as additional-properties (cli) or configOptions (pl |generateBuilders|Whether to generate builders for models| |false| |generateConstructorWithAllArgs|whether to generate a constructor for all arguments| |false| |generateGenericResponseEntity|Use a generic type for the `ResponseEntity` wrapping return values of generated API methods. If enabled, method are generated with return type ResponseEntity<?>| |false| -|generatePageableConstraintValidation|Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to paginated operations whose 'page' or 'size' parameter has a maximum constraint. Requires useBeanValidation=true and library=spring-boot.| |false| -|generateSortValidation|Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to paginated operations whose 'sort' parameter has enum values. Requires useBeanValidation=true and library=spring-boot.| |false| +|generatePageableConstraintValidation|Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to the injected Pageable parameter of operations whose 'page' or 'size' parameter specifies a maximum constraint. The annotation enforces those constraints on the Pageable object that replaces the individual page/size query parameters. Requires useBeanValidation=true and library=spring-boot.| |false| +|generateSortValidation|Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to the injected Pageable parameter of operations whose 'sort' parameter has enum values. The annotation validates that sort values in the Pageable object match the allowed enum values from the spec. Requires useBeanValidation=true and library=spring-boot.| |false| |generatedConstructorWithRequiredArgs|Whether to generate constructors with required args for models| |true| |groupId|groupId in generated pom.xml| |org.openapitools| |hateoas|Use Spring HATEOAS library to allow adding HATEOAS links| |false| 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 067365ececff..e894cc25000d 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 @@ -288,8 +288,8 @@ public KotlinSpringServerCodegen() { addOption(SCHEMA_IMPLEMENTS, "A map of single interface or a list of interfaces per schema name that should be implemented (serves similar purpose as `x-kotlin-implements`, but is fully decoupled from the api spec). Example: yaml `schemaImplements: {Pet: com.some.pack.WithId, Category: [com.some.pack.CategoryInterface], Dog: [com.some.pack.Canine, com.some.pack.OtherInterface]}` implements interfaces in schemas `Pet` (interface `com.some.pack.WithId`), `Category` (interface `com.some.pack.CategoryInterface`), `Dog`(interfaces `com.some.pack.Canine`, `com.some.pack.OtherInterface`)", "empty map"); addOption(SCHEMA_IMPLEMENTS_FIELDS, "A map of single field or a list of fields per schema name that should be prepended with `override` (serves similar purpose as `x-kotlin-implements-fields`, but is fully decoupled from the api spec). Example: yaml `schemaImplementsFields: {Pet: id, Category: [name, id], Dog: [bark, breed]}` marks fields to be prepended with `override` in schemas `Pet` (field `id`), `Category` (fields `name`, `id`) and `Dog` (fields `bark`, `breed`)", "empty map"); addSwitch(AUTO_X_SPRING_PAGINATED, "Automatically add x-spring-paginated to operations that have 'page', 'size', and 'sort' query parameters. When enabled, operations with all three parameters will have Pageable support automatically applied. Operations with x-spring-paginated explicitly set to false will not be auto-detected.", autoXSpringPaginated); - addSwitch(GENERATE_SORT_VALIDATION, "Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to paginated operations whose 'sort' parameter has enum values. Requires useBeanValidation=true and library=spring-boot.", generateSortValidation); - addSwitch(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to paginated operations whose 'page' or 'size' parameter has a maximum constraint. Requires useBeanValidation=true and library=spring-boot.", generatePageableConstraintValidation); + addSwitch(GENERATE_SORT_VALIDATION, "Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to the injected Pageable parameter of operations whose 'sort' parameter has enum values. The annotation validates that sort values in the Pageable object match the allowed enum values from the spec. Requires useBeanValidation=true and library=spring-boot.", generateSortValidation); + addSwitch(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to the injected Pageable parameter of operations whose 'page' or 'size' parameter specifies a maximum constraint. The annotation enforces those constraints on the Pageable object that replaces the individual page/size query parameters. Requires useBeanValidation=true and library=spring-boot.", generatePageableConstraintValidation); addSwitch(COMPANION_OBJECT, "Whether to generate companion objects in data classes, enabling companion extensions.", companionObject); supportedLibraries.put(SPRING_BOOT, "Spring-boot Server application."); supportedLibraries.put(SPRING_CLOUD_LIBRARY, 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 ccd6bd7259ac..8a340845468e 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 @@ -358,12 +358,15 @@ public SpringCodegen() { + "Only applies when library=spring-boot.", autoXSpringPaginated)); cliOptions.add(CliOption.newBoolean(GENERATE_SORT_VALIDATION, - "Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to paginated operations " - + "whose 'sort' parameter has enum values. Requires useBeanValidation=true and library=spring-boot.", + "Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to " + + "the injected Pageable parameter of operations whose 'sort' parameter has enum values. " + + "The annotation validates that sort values in the Pageable object match the allowed enum values from the spec. " + + "Requires useBeanValidation=true and library=spring-boot.", generateSortValidation)); cliOptions.add(CliOption.newBoolean(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to " - + "paginated operations whose 'page' or 'size' parameter has a maximum constraint. " + + "the injected Pageable parameter of operations whose 'page' or 'size' parameter specifies a maximum constraint. " + + "The annotation enforces those constraints on the Pageable object that replaces the individual page/size query parameters. " + "Requires useBeanValidation=true and library=spring-boot.", generatePageableConstraintValidation)); From f1935b48be656299817ea8d9d71eb31fccc35715 Mon Sep 17 00:00:00 2001 From: Jachym Metlicka Date: Wed, 15 Apr 2026 00:04:36 +0200 Subject: [PATCH 15/15] improve sample --- .../kotlin-spring-boot-sort-validation.yaml | 1 + .../.openapi-generator/FILES | 1 + .../org/openapitools/api/PetApiController.kt | 5 +- .../org/openapitools/api/PetApiService.kt | 1 + .../org/openapitools/api/PetApiServiceImpl.kt | 1 + .../configuration/ValidPageable.kt | 80 +++++++++++++++++++ 6 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidPageable.kt diff --git a/bin/configs/kotlin-spring-boot-sort-validation.yaml b/bin/configs/kotlin-spring-boot-sort-validation.yaml index 22973c2f36e2..ada874ecb8a2 100644 --- a/bin/configs/kotlin-spring-boot-sort-validation.yaml +++ b/bin/configs/kotlin-spring-boot-sort-validation.yaml @@ -12,5 +12,6 @@ additionalProperties: beanValidations: "true" useSpringBoot3: "true" generateSortValidation: "true" + generatePageableConstraintValidation: "true" useTags: "true" requestMappingMode: api_interface diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES b/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES index 39125d70752e..2e3293272a7d 100644 --- a/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES +++ b/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES @@ -13,6 +13,7 @@ src/main/kotlin/org/openapitools/api/PetApiController.kt src/main/kotlin/org/openapitools/api/PetApiService.kt src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt src/main/kotlin/org/openapitools/configuration/EnumConverterConfiguration.kt +src/main/kotlin/org/openapitools/configuration/ValidPageable.kt src/main/kotlin/org/openapitools/configuration/ValidSort.kt src/main/kotlin/org/openapitools/model/Pet.kt src/main/kotlin/org/openapitools/model/PetSort.kt diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiController.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiController.kt index b5330926a2c9..11b839d6c4bc 100644 --- a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiController.kt +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiController.kt @@ -6,6 +6,7 @@ import org.openapitools.model.Pet import org.openapitools.model.PetSort import org.springframework.data.domain.Sort import org.springframework.data.web.SortDefault +import org.openapitools.configuration.ValidPageable import org.openapitools.configuration.ValidSort import org.springframework.http.HttpStatus import org.springframework.http.MediaType @@ -91,7 +92,7 @@ class PetApiController(@Autowired(required = true) val service: PetApiService) { value = [PATH_FIND_PETS_WITH_PAGE_AND_SIZE_CONSTRAINT], produces = ["application/json"] ) - fun findPetsWithPageAndSizeConstraint(pageable: Pageable): ResponseEntity> { + fun findPetsWithPageAndSizeConstraint(@ValidPageable(maxSize = 50, maxPage = 999) pageable: Pageable): ResponseEntity> { return ResponseEntity(service.findPetsWithPageAndSizeConstraint(), HttpStatus.valueOf(200)) } @@ -124,7 +125,7 @@ class PetApiController(@Autowired(required = true) val service: PetApiService) { value = [PATH_FIND_PETS_WITH_SIZE_CONSTRAINT], produces = ["application/json"] ) - fun findPetsWithSizeConstraint(pageable: Pageable): ResponseEntity> { + fun findPetsWithSizeConstraint(@ValidPageable(maxSize = 100) pageable: Pageable): ResponseEntity> { return ResponseEntity(service.findPetsWithSizeConstraint(), HttpStatus.valueOf(200)) } diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiService.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiService.kt index 7318c2b8605f..d739e8d789df 100644 --- a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiService.kt +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiService.kt @@ -6,6 +6,7 @@ import org.openapitools.model.Pet import org.openapitools.model.PetSort import org.springframework.data.domain.Sort import org.springframework.data.web.SortDefault +import org.openapitools.configuration.ValidPageable import org.openapitools.configuration.ValidSort interface PetApiService { diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt index b2e851ba404f..05241b49f37d 100644 --- a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt @@ -6,6 +6,7 @@ import org.openapitools.model.Pet import org.openapitools.model.PetSort import org.springframework.data.domain.Sort import org.springframework.data.web.SortDefault +import org.openapitools.configuration.ValidPageable import org.openapitools.configuration.ValidSort import org.springframework.stereotype.Service @Service 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 new file mode 100644 index 000000000000..c9d34fd6ab0f --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidPageable.kt @@ -0,0 +1,80 @@ +package org.openapitools.configuration + +import jakarta.validation.Constraint +import jakarta.validation.ConstraintValidator +import jakarta.validation.ConstraintValidatorContext +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. + * + * 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` + * + * Use [NO_LIMIT] (= -1, the default) to leave an attribute unconstrained. + * + * Constraining [maxPage] is useful to prevent deep-pagination attacks, where a large page + * offset (e.g. `?page=100000&size=20`) causes an expensive `OFFSET` query on the database. + * + * @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 groups Validation groups (optional) + * @property payload Additional payload (optional) + * @property message Validation error message (default: "Invalid page request") + */ +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +@Constraint(validatedBy = [PageableConstraintValidator::class]) +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class ValidPageable( + val maxSize: Int = ValidPageable.NO_LIMIT, + val maxPage: Int = ValidPageable.NO_LIMIT, + val groups: Array> = [], + val payload: Array> = [], + val message: String = "Invalid page request" +) { + companion object { + const val NO_LIMIT = -1 + } +} + +class PageableConstraintValidator : ConstraintValidator { + + private var maxSize = ValidPageable.NO_LIMIT + private var maxPage = ValidPageable.NO_LIMIT + + override fun initialize(constraintAnnotation: ValidPageable) { + maxSize = constraintAnnotation.maxSize + maxPage = constraintAnnotation.maxPage + } + + override fun isValid(pageable: Pageable?, context: ConstraintValidatorContext): Boolean { + if (pageable == null) return true + + var valid = true + context.disableDefaultConstraintViolation() + + if (maxSize >= 0 && pageable.pageSize > maxSize) { + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate}: page size ${pageable.pageSize} exceeds maximum $maxSize" + ) + .addPropertyNode("size") + .addConstraintViolation() + valid = false + } + + if (maxPage >= 0 && pageable.pageNumber > maxPage) { + context.buildConstraintViolationWithTemplate( + "${context.defaultConstraintMessageTemplate}: page number ${pageable.pageNumber} exceeds maximum $maxPage" + ) + .addPropertyNode("page") + .addConstraintViolation() + valid = false + } + + return valid + } +}