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: 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..9711f5a35149 --- /dev/null +++ b/bin/configs/kotlin-spring-boot-sort-validation.yaml @@ -0,0 +1,18 @@ +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" + generatePageableConstraintValidation: "true" + hideGenerationTimestamp: "true" + useTags: "true" + requestMappingMode: api_interface diff --git a/bin/configs/spring-boot-sort-validation.yaml b/bin/configs/spring-boot-sort-validation.yaml new file mode 100644 index 000000000000..9f1198e0a4ec --- /dev/null +++ b/bin/configs/spring-boot-sort-validation.yaml @@ -0,0 +1,17 @@ +generatorName: spring +outputDir: samples/server/petstore/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/JavaSpring +additionalProperties: + documentationProvider: none + annotationLibrary: none + useSwaggerUI: "false" + serviceImplementation: "true" + serializableModel: "true" + beanValidations: "true" + useSpringBoot3: "true" + generateSortValidation: "true" + generatePageableConstraintValidation: "true" + useTags: "true" + requestMappingMode: api_interface diff --git a/docs/generators/java-camel.md b/docs/generators/java-camel.md index 69aa6401cd81..951973049540 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 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 580d834f52c0..90828667f3a5 100644 --- a/docs/generators/kotlin-spring.md +++ b/docs/generators/kotlin-spring.md @@ -34,6 +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 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 ccbb5e3443c7..be715a2763db 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 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 5dc081bbd3ab..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 @@ -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,8 @@ 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 GENERATE_PAGEABLE_CONSTRAINT_VALIDATION = "generatePageableConstraintValidation"; public static final String USE_SEALED_RESPONSE_INTERFACES = "useSealedResponseInterfaces"; public static final String COMPANION_OBJECT = "companionObject"; @@ -164,6 +169,8 @@ 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 generatePageableConstraintValidation = false; @Setter private boolean useSealedResponseInterfaces = false; @Setter private boolean companionObject = false; @@ -180,6 +187,15 @@ public String getDescription() { private Map sealedInterfaceToOperationId = new HashMap<>(); private boolean sealedInterfacesFileWritten = false; + // Map from operationId to allowed sort values for @ValidSort annotation generation + private Map> sortValidationEnums = new HashMap<>(); + + // Map from operationId to pageable defaults for @PageableDefault/@SortDefault annotation generation + private Map pageableDefaultsRegistry = new HashMap<>(); + + // Map from operationId to pageable constraints for @ValidPageable annotation generation + private Map pageableConstraintsRegistry = new HashMap<>(); + public KotlinSpringServerCodegen() { super(); @@ -272,6 +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 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, @@ -704,6 +722,14 @@ 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 (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"); } @@ -1042,6 +1068,52 @@ 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 (@ValidPageable, @ValidSort, @PageableDefault, @SortDefault.SortDefaults) + List pageableAnnotations = new ArrayList<>(); + + if (generatePageableConstraintValidation && useBeanValidation && pageableConstraintsRegistry.containsKey(codegenOperation.operationId)) { + SpringPageableScanUtils.PageableConstraintsData constraints = pageableConstraintsRegistry.get(codegenOperation.operationId); + List attrs = new ArrayList<>(); + if (constraints.maxSize >= 0) attrs.add("maxSize = " + constraints.maxSize); + if (constraints.maxPage >= 0) attrs.add("maxPage = " + constraints.maxPage); + pageableAnnotations.add("@ValidPageable(" + String.join(", ", attrs) + ")"); + codegenOperation.imports.add("ValidPageable"); + } + + if (generateSortValidation && useBeanValidation && sortValidationEnums.containsKey(codegenOperation.operationId)) { + List allowedSortValues = sortValidationEnums.get(codegenOperation.operationId); + String allowedValuesStr = allowedSortValues.stream() + .map(v -> "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\"") + .collect(Collectors.joining(", ")); + pageableAnnotations.add("@ValidSort(allowedValues = [" + allowedValuesStr + "])"); + codegenOperation.imports.add("ValidSort"); + } + + // Generate @PageableDefault / @SortDefault.SortDefaults annotations if defaults are present + if (pageableDefaultsRegistry.containsKey(codegenOperation.operationId)) { + SpringPageableScanUtils.PageableDefaultsData defaults = pageableDefaultsRegistry.get(codegenOperation.operationId); + + if (defaults.page != null || defaults.size != null) { + List attrs = new ArrayList<>(); + if (defaults.page != null) attrs.add("page = " + defaults.page); + if (defaults.size != null) attrs.add("size = " + defaults.size); + pageableAnnotations.add("@PageableDefault(" + String.join(", ", attrs) + ")"); + codegenOperation.imports.add("PageableDefault"); + } + + if (!defaults.sortDefaults.isEmpty()) { + List sortEntries = defaults.sortDefaults.stream() + .map(sf -> "SortDefault(sort = [\"" + sf.field + "\"], direction = Sort.Direction." + sf.direction + ")") + .collect(Collectors.toList()); + pageableAnnotations.add("@SortDefault.SortDefaults(" + String.join(", ", sortEntries) + ")"); + codegenOperation.imports.add("SortDefault"); + codegenOperation.imports.add("Sort"); + } + } + + if (!pageableAnnotations.isEmpty()) { + codegenOperation.vendorExtensions.put("x-pageable-extra-annotation", pageableAnnotations); + } codegenOperation.queryParams.removeIf(param -> defaultPageableQueryParams.contains(param.baseName)); codegenOperation.allParams.removeIf(param -> param.isQueryParam && defaultPageableQueryParams.contains(param.baseName)); } @@ -1058,6 +1130,33 @@ public void preprocessOpenAPI(OpenAPI openAPI) { (sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "EnumConverterConfiguration.kt")); } + if (SPRING_BOOT.equals(library) && generateSortValidation && useBeanValidation) { + sortValidationEnums = SpringPageableScanUtils.scanSortValidationEnums(openAPI, autoXSpringPaginated); + if (!sortValidationEnums.isEmpty()) { + importMapping.putIfAbsent("ValidSort", configPackage + ".ValidSort"); + supportingFiles.add(new SupportingFile("validSort.mustache", + (sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidSort.kt")); + } + } + + if (SPRING_BOOT.equals(library)) { + pageableDefaultsRegistry = SpringPageableScanUtils.scanPageableDefaults(openAPI, autoXSpringPaginated); + if (!pageableDefaultsRegistry.isEmpty()) { + importMapping.putIfAbsent("PageableDefault", "org.springframework.data.web.PageableDefault"); + importMapping.putIfAbsent("SortDefault", "org.springframework.data.web.SortDefault"); + importMapping.putIfAbsent("Sort", "org.springframework.data.domain.Sort"); + } + } + + if (SPRING_BOOT.equals(library) && generatePageableConstraintValidation && useBeanValidation) { + pageableConstraintsRegistry = SpringPageableScanUtils.scanPageableConstraints(openAPI, autoXSpringPaginated); + if (!pageableConstraintsRegistry.isEmpty()) { + importMapping.putIfAbsent("ValidPageable", configPackage + ".ValidPageable"); + supportingFiles.add(new SupportingFile("validPageable.mustache", + (sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidPageable.kt")); + } + } + if (!additionalProperties.containsKey(TITLE)) { // The purpose of the title is for: // - README documentation @@ -1123,6 +1222,14 @@ public void preprocessOpenAPI(OpenAPI openAPI) { // TODO: Handle tags } + /** + * Returns true if the given operation will have a Pageable parameter injected. + * Delegates to {@link SpringPageableScanUtils#willBePageable}. + */ + private boolean willBePageable(Operation operation) { + return SpringPageableScanUtils.willBePageable(operation, autoXSpringPaginated); + } + @Override public void postProcessModelProperty(CodegenModel model, CodegenProperty property) { super.postProcessModelProperty(model, property); diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java index e6a150c4e21a..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 @@ -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,24 @@ 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 " + + "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 " + + "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)); } @@ -547,6 +578,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 +829,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 +1178,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 +1224,52 @@ 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()); + 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/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/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/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/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/main/resources/kotlin-spring/validSort.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/validSort.mustache new file mode 100644 index 000000000000..7b3f7cf0adbd --- /dev/null +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/validSort.mustache @@ -0,0 +1,87 @@ +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 sort properties in the annotated [Pageable] parameter match the allowed values. + * + * 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]. + * + * 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) + * @property payload Additional payload (optional) + * @property message Validation error message (default: "Invalid sort column") + */ +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +@Constraint(validatedBy = [SortValidator::class]) +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class ValidSort( + val allowedValues: Array, + val groups: Array> = [], + val payload: Array> = [], + val message: String = "Invalid sort column" +) + +class SortValidator : ConstraintValidator { + + private lateinit var allowedValues: Set + + override fun initialize(constraintAnnotation: ValidSort) { + 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(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()}" + // 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() + + 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/java/spring/SpringCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/spring/SpringCodegenTest.java index 96444d9aee77..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 @@ -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({@SortDefault(sort = {\"name\"}, direction = Sort.Direction.DESC)})"); + } + + @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({@SortDefault(sort = {\"name\"}, direction = Sort.Direction.DESC), @SortDefault(sort = {\"id\"}, direction = Sort.Direction.ASC)})"); + } } 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..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 @@ -4160,8 +4160,364 @@ 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 ========== + + @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"); + + // @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 + 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(), "DIRECTION_ASC_SUFFIX"); + assertFileContains(validSortFile.toPath(), "DIRECTION_DESC_SUFFIX"); + } + + @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"); + } + + // ========== 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 new file mode 100644 index 000000000000..a795ff7a2e54 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml @@ -0,0 +1,431 @@ +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' + + # ---- 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' + /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: + 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 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/.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..2e3293272a7d --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES @@ -0,0 +1,20 @@ +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/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 +src/main/resources/application.yaml 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 000000000000..e6441136f3d4 Binary files /dev/null and b/samples/server/petstore/kotlin-springboot-sort-validation/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/gradle/wrapper/gradle-wrapper.properties b/samples/server/petstore/kotlin-springboot-sort-validation/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..80187ac30432 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/gradlew b/samples/server/petstore/kotlin-springboot-sort-validation/gradlew new file mode 100644 index 000000000000..9d0ce634cb11 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while +APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path +[ -h "$app_path" ] +do +ls=$( ls -ld "$app_path" ) +link=${ls#*' -> '} +case $link in #( +/*) app_path=$link ;; #( +*) app_path=$APP_HOME$link ;; +esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { +echo "$*" +} >&2 + +die () { +echo +echo "$*" +echo +exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( +CYGWIN* ) cygwin=true ;; #( +Darwin* ) darwin=true ;; #( +MSYS* | MINGW* ) msys=true ;; #( +NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then +if [ -x "$JAVA_HOME/jre/sh/java" ] ; then +# IBM's JDK on AIX uses strange locations for the executables +JAVACMD=$JAVA_HOME/jre/sh/java +else +JAVACMD=$JAVA_HOME/bin/java +fi +if [ ! -x "$JAVACMD" ] ; then +die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi +else +JAVACMD=java +if ! command -v java >/dev/null 2>&1 +then +die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then +case $MAX_FD in #( +max*) +# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. +# shellcheck disable=SC2039,SC3045 +MAX_FD=$( ulimit -H -n ) || +warn "Could not query maximum file descriptor limit" +esac +case $MAX_FD in #( +'' | soft) :;; #( +*) +# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. +# shellcheck disable=SC2039,SC3045 +ulimit -n "$MAX_FD" || +warn "Could not set maximum file descriptor limit to $MAX_FD" +esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then +APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) +CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + +JAVACMD=$( cygpath --unix "$JAVACMD" ) + +# Now convert the arguments - kludge to limit ourselves to /bin/sh +for arg do +if +case $arg in #( +-*) false ;; # don't mess with options #( +/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath +[ -e "$t" ] ;; #( +*) false ;; +esac +then +arg=$( cygpath --path --ignore --mixed "$arg" ) +fi +# Roll the args list around exactly as many times as the number of +# args, so each arg winds up back in the position where it started, but +# possibly modified. +# +# NB: a `for` loop captures its iteration list before it begins, so +# changing the positional parameters here affects neither the number of +# iterations, nor the values presented in `arg`. +shift # remove old arg +set -- "$@" "$arg" # push replacement arg +done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ +"-Dorg.gradle.appname=$APP_BASE_NAME" \ +-classpath "$CLASSPATH" \ +org.gradle.wrapper.GradleWrapperMain \ +"$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then +die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( +printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | +xargs -n1 | +sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | +tr '\n' ' ' +)" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/samples/server/petstore/kotlin-springboot-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..11b839d6c4bc --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiController.kt @@ -0,0 +1,194 @@ +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.ValidPageable +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)) + } + + + @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(@ValidPageable(maxSize = 50, maxPage = 999) 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(@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)) + } + + + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithSizeConstraint" + value = [PATH_FIND_PETS_WITH_SIZE_CONSTRAINT], + produces = ["application/json"] + ) + fun findPetsWithSizeConstraint(@ValidPageable(maxSize = 100) 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" + value = [PATH_FIND_PETS_WITH_SORT_ENUM], + produces = ["application/json"] + ) + fun findPetsWithSortEnum( + @Valid @RequestParam(value = "status", required = false) status: kotlin.String?, + @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)) + } + + + @RequestMapping( + method = [RequestMethod.GET], + // "/pet/findWithoutSortEnum" + value = [PATH_FIND_PETS_WITHOUT_SORT_ENUM], + produces = ["application/json"] + ) + fun findPetsWithoutSortEnum(@PageableDefault(page = 0, size = 20) 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_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 new file mode 100644 index 000000000000..d739e8d789df --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiService.kt @@ -0,0 +1,115 @@ +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.ValidPageable +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/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 + * + * @return successful operation (status code 200) + * @see PetApi#findPetsWithRefSort + */ + 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 + * + * @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..05241b49f37d --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/PetApiServiceImpl.kt @@ -0,0 +1,62 @@ +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.ValidPageable +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 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") + } + + 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/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 + } +} 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..5c96f1c82814 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/configuration/ValidSort.kt @@ -0,0 +1,87 @@ +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 sort properties in the annotated [Pageable] parameter match the allowed values. + * + * 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]. + * + * 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) + * @property payload Additional payload (optional) + * @property message Validation error message (default: "Invalid sort column") + */ +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +@Constraint(validatedBy = [SortValidator::class]) +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class ValidSort( + val allowedValues: Array, + val groups: Array> = [], + val payload: Array> = [], + val message: String = "Invalid sort column" +) + +class SortValidator : ConstraintValidator { + + private lateinit var allowedValues: Set + + override fun initialize(constraintAnnotation: ValidSort) { + 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(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()}" + // 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() + + 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 + } +} 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( diff --git a/samples/server/petstore/springboot-sort-validation/.openapi-generator-ignore b/samples/server/petstore/springboot-sort-validation/.openapi-generator-ignore new file mode 100644 index 000000000000..7484ee590a38 --- /dev/null +++ b/samples/server/petstore/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/springboot-sort-validation/.openapi-generator/FILES b/samples/server/petstore/springboot-sort-validation/.openapi-generator/FILES new file mode 100644 index 000000000000..4082f0e9fd06 --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/.openapi-generator/FILES @@ -0,0 +1,15 @@ +README.md +pom.xml +src/main/java/org/openapitools/OpenApiGeneratorApplication.java +src/main/java/org/openapitools/RFC3339DateFormat.java +src/main/java/org/openapitools/api/ApiUtil.java +src/main/java/org/openapitools/api/PetApi.java +src/main/java/org/openapitools/configuration/EnumConverterConfiguration.java +src/main/java/org/openapitools/configuration/HomeController.java +src/main/java/org/openapitools/configuration/ValidPageable.java +src/main/java/org/openapitools/configuration/ValidSort.java +src/main/java/org/openapitools/model/Pet.java +src/main/java/org/openapitools/model/PetSort.java +src/main/resources/application.properties +src/main/resources/openapi.yaml +src/test/java/org/openapitools/OpenApiGeneratorApplicationTests.java diff --git a/samples/server/petstore/springboot-sort-validation/.openapi-generator/VERSION b/samples/server/petstore/springboot-sort-validation/.openapi-generator/VERSION new file mode 100644 index 000000000000..f7962df3e243 --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.22.0-SNAPSHOT diff --git a/samples/server/petstore/springboot-sort-validation/README.md b/samples/server/petstore/springboot-sort-validation/README.md new file mode 100644 index 000000000000..7e955e89350e --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/README.md @@ -0,0 +1,12 @@ +# OpenAPI generated server + +Spring Boot Server + +## Overview +This server was generated by the [OpenAPI Generator](https://openapi-generator.tech) project. +By using the [OpenAPI-Spec](https://openapis.org), you can easily generate a server stub. +This is an example of building a OpenAPI-enabled server in Java using the SpringBoot framework. + + +Start your server as a simple java application +Change default port value in application.properties \ No newline at end of file diff --git a/samples/server/petstore/springboot-sort-validation/pom.xml b/samples/server/petstore/springboot-sort-validation/pom.xml new file mode 100644 index 000000000000..adb270620c8c --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/pom.xml @@ -0,0 +1,70 @@ + + 4.0.0 + org.openapitools + openapi-spring + jar + openapi-spring + 1.0.0 + + 17 + ${java.version} + UTF-8 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.13 + + + + + src/main/java + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.data + spring-data-commons + + + + com.google.code.findbugs + jsr305 + 3.0.2 + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + org.openapitools + jackson-databind-nullable + 0.2.10 + + + + org.springframework.boot + spring-boot-starter-validation + + + com.fasterxml.jackson.core + jackson-databind + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/OpenApiGeneratorApplication.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/OpenApiGeneratorApplication.java new file mode 100644 index 000000000000..97252a8a9402 --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/OpenApiGeneratorApplication.java @@ -0,0 +1,30 @@ +package org.openapitools; + +import com.fasterxml.jackson.databind.Module; +import org.openapitools.jackson.nullable.JsonNullableModule; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.FullyQualifiedAnnotationBeanNameGenerator; + +@SpringBootApplication( + nameGenerator = FullyQualifiedAnnotationBeanNameGenerator.class +) +@ComponentScan( + basePackages = {"org.openapitools", "org.openapitools.api" , "org.openapitools.configuration"}, + nameGenerator = FullyQualifiedAnnotationBeanNameGenerator.class +) +public class OpenApiGeneratorApplication { + + public static void main(String[] args) { + SpringApplication.run(OpenApiGeneratorApplication.class, args); + } + + @Bean(name = "org.openapitools.OpenApiGeneratorApplication.jsonNullableModule") + public Module jsonNullableModule() { + return new JsonNullableModule(); + } + +} \ No newline at end of file diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/RFC3339DateFormat.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/RFC3339DateFormat.java new file mode 100644 index 000000000000..bcd3936d8b34 --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/RFC3339DateFormat.java @@ -0,0 +1,38 @@ +package org.openapitools; + +import com.fasterxml.jackson.databind.util.StdDateFormat; + +import java.text.DateFormat; +import java.text.FieldPosition; +import java.text.ParsePosition; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.TimeZone; + +public class RFC3339DateFormat extends DateFormat { + private static final long serialVersionUID = 1L; + private static final TimeZone TIMEZONE_Z = TimeZone.getTimeZone("UTC"); + + private final StdDateFormat fmt = new StdDateFormat() + .withTimeZone(TIMEZONE_Z) + .withColonInTimeZone(true); + + public RFC3339DateFormat() { + this.calendar = new GregorianCalendar(); + } + + @Override + public Date parse(String source, ParsePosition pos) { + return fmt.parse(source, pos); + } + + @Override + public StringBuffer format(Date date, StringBuffer toAppendTo, FieldPosition fieldPosition) { + return fmt.format(date, toAppendTo, fieldPosition); + } + + @Override + public Object clone() { + return this; + } +} \ No newline at end of file diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/ApiUtil.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/ApiUtil.java new file mode 100644 index 000000000000..44bf770ccc47 --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/ApiUtil.java @@ -0,0 +1,21 @@ +package org.openapitools.api; + +import org.springframework.web.context.request.NativeWebRequest; + +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +public class ApiUtil { + public static void setExampleResponse(NativeWebRequest req, String contentType, String example) { + try { + HttpServletResponse res = req.getNativeResponse(HttpServletResponse.class); + if (res != null) { + res.setCharacterEncoding("UTF-8"); + res.addHeader("Content-Type", contentType); + res.getWriter().print(example); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApi.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApi.java new file mode 100644 index 000000000000..ba5bf8cfa933 --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApi.java @@ -0,0 +1,386 @@ +/* + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.22.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.openapitools.api; + +import org.springframework.lang.Nullable; +import org.springframework.data.domain.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.ValidPageable; +import org.openapitools.configuration.ValidSort; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import jakarta.annotation.Generated; + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2026-04-14T23:14:13.089173701Z[UTC]", comments = "Generator version: 7.22.0-SNAPSHOT") +@Validated +@RequestMapping("${openapi.openAPIPetstoreSortValidationTest.base-path:/v2}") +public interface PetApi { + + default Optional getRequest() { + return Optional.empty(); + } + + String PATH_FIND_PETS_AUTO_DETECTED_WITH_SORT = "/pet/findAutoDetectedWithSort"; + /** + * 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) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_AUTO_DETECTED_WITH_SORT, + produces = { "application/json" } + ) + default ResponseEntity> findPetsAutoDetectedWithSort( + @Valid @RequestParam(value = "status", required = false) @Nullable String status, + @Valid @RequestParam(value = "page", required = false, defaultValue = "0") Integer page, + @Valid @RequestParam(value = "size", required = false, defaultValue = "20") Integer size, + @Valid @RequestParam(value = "sort", required = false) @Nullable String sort + ) { + getRequest().ifPresent(request -> { + for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { + if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { + String exampleString = "[ { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" }, { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" } ]"; + ApiUtil.setExampleResponse(request, "application/json", exampleString); + break; + } + } + }); + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + + } + + + String PATH_FIND_PETS_NON_PAGINATED_WITH_SORT_ENUM = "/pet/findNonPaginatedWithSortEnum"; + /** + * 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) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_NON_PAGINATED_WITH_SORT_ENUM, + produces = { "application/json" } + ) + default ResponseEntity> findPetsNonPaginatedWithSortEnum( + @Valid @RequestParam(value = "sort", required = false) @Nullable String sort + ) { + getRequest().ifPresent(request -> { + for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { + if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { + String exampleString = "[ { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" }, { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" } ]"; + ApiUtil.setExampleResponse(request, "application/json", exampleString); + break; + } + } + }); + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + + } + + + String PATH_FIND_PETS_WITH_ALL_DEFAULTS = "/pet/findWithAllDefaults"; + /** + * GET /pet/findWithAllDefaults : Find pets — page, size, and mixed sort defaults all present + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_ALL_DEFAULTS, + produces = { "application/json" } + ) + default ResponseEntity> findPetsWithAllDefaults( + @PageableDefault(page = 0, size = 10) @SortDefault.SortDefaults({@SortDefault(sort = {"name"}, direction = Sort.Direction.DESC), @SortDefault(sort = {"id"}, direction = Sort.Direction.ASC)}) final Pageable pageable + ) { + getRequest().ifPresent(request -> { + for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { + if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { + String exampleString = "[ { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" }, { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" } ]"; + ApiUtil.setExampleResponse(request, "application/json", exampleString); + break; + } + } + }); + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + + } + + + String PATH_FIND_PETS_WITH_MIXED_SORT_DEFAULTS = "/pet/findWithMixedSortDefaults"; + /** + * GET /pet/findWithMixedSortDefaults : Find pets — multiple sort defaults with mixed directions (array sort param) + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_MIXED_SORT_DEFAULTS, + produces = { "application/json" } + ) + default ResponseEntity> findPetsWithMixedSortDefaults( + @SortDefault.SortDefaults({@SortDefault(sort = {"name"}, direction = Sort.Direction.DESC), @SortDefault(sort = {"id"}, direction = Sort.Direction.ASC)}) final Pageable pageable + ) { + getRequest().ifPresent(request -> { + for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { + if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { + String exampleString = "[ { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" }, { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" } ]"; + ApiUtil.setExampleResponse(request, "application/json", exampleString); + break; + } + } + }); + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + + } + + + String PATH_FIND_PETS_WITH_PAGE_AND_SIZE_CONSTRAINT = "/pet/findWithPageAndSizeConstraint"; + /** + * GET /pet/findWithPageAndSizeConstraint : Find pets — both page and size have maximum constraints + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_PAGE_AND_SIZE_CONSTRAINT, + produces = { "application/json" } + ) + default ResponseEntity> findPetsWithPageAndSizeConstraint( + @ValidPageable(maxSize = 50, maxPage = 999) final Pageable pageable + ) { + getRequest().ifPresent(request -> { + for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { + if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { + String exampleString = "[ { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" }, { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" } ]"; + ApiUtil.setExampleResponse(request, "application/json", exampleString); + break; + } + } + }); + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + + } + + + String PATH_FIND_PETS_WITH_PAGE_SIZE_DEFAULTS_ONLY = "/pet/findWithPageSizeDefaultsOnly"; + /** + * GET /pet/findWithPageSizeDefaultsOnly : Find pets — page and size defaults only, no sort default + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_PAGE_SIZE_DEFAULTS_ONLY, + produces = { "application/json" } + ) + default ResponseEntity> findPetsWithPageSizeDefaultsOnly( + @PageableDefault(page = 0, size = 25) final Pageable pageable + ) { + getRequest().ifPresent(request -> { + for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { + if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { + String exampleString = "[ { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" }, { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" } ]"; + ApiUtil.setExampleResponse(request, "application/json", exampleString); + break; + } + } + }); + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + + } + + + String PATH_FIND_PETS_WITH_REF_SORT = "/pet/findWithRefSort"; + /** + * GET /pet/findWithRefSort : Find pets with x-spring-paginated and $ref sort enum + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_REF_SORT, + produces = { "application/json" } + ) + default ResponseEntity> findPetsWithRefSort( + @ValidSort(allowedValues = {"id,asc", "id,desc", "createdAt,asc", "createdAt,desc"}) @PageableDefault(page = 0, size = 20) final Pageable pageable + ) { + getRequest().ifPresent(request -> { + for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { + if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { + String exampleString = "[ { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" }, { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" } ]"; + ApiUtil.setExampleResponse(request, "application/json", exampleString); + break; + } + } + }); + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + + } + + + String PATH_FIND_PETS_WITH_SIZE_CONSTRAINT = "/pet/findWithSizeConstraint"; + /** + * GET /pet/findWithSizeConstraint : Find pets — size has maximum constraint only + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_SIZE_CONSTRAINT, + produces = { "application/json" } + ) + default ResponseEntity> findPetsWithSizeConstraint( + @ValidPageable(maxSize = 100) final Pageable pageable + ) { + getRequest().ifPresent(request -> { + for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { + if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { + String exampleString = "[ { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" }, { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" } ]"; + ApiUtil.setExampleResponse(request, "application/json", exampleString); + break; + } + } + }); + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + + } + + + String PATH_FIND_PETS_WITH_SORT_DEFAULT_ASC = "/pet/findWithSortDefaultAsc"; + /** + * GET /pet/findWithSortDefaultAsc : Find pets — sort default only (single field, no explicit direction defaults to ASC) + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_SORT_DEFAULT_ASC, + produces = { "application/json" } + ) + default ResponseEntity> findPetsWithSortDefaultAsc( + @SortDefault.SortDefaults({@SortDefault(sort = {"id"}, direction = Sort.Direction.ASC)}) final Pageable pageable + ) { + getRequest().ifPresent(request -> { + for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { + if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { + String exampleString = "[ { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" }, { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" } ]"; + ApiUtil.setExampleResponse(request, "application/json", exampleString); + break; + } + } + }); + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + + } + + + String PATH_FIND_PETS_WITH_SORT_DEFAULT_ONLY = "/pet/findWithSortDefaultOnly"; + /** + * GET /pet/findWithSortDefaultOnly : Find pets — sort default only (single field DESC, no page/size defaults) + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_SORT_DEFAULT_ONLY, + produces = { "application/json" } + ) + default ResponseEntity> findPetsWithSortDefaultOnly( + @SortDefault.SortDefaults({@SortDefault(sort = {"name"}, direction = Sort.Direction.DESC)}) final Pageable pageable + ) { + getRequest().ifPresent(request -> { + for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { + if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { + String exampleString = "[ { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" }, { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" } ]"; + ApiUtil.setExampleResponse(request, "application/json", exampleString); + break; + } + } + }); + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + + } + + + String PATH_FIND_PETS_WITH_SORT_ENUM = "/pet/findByStatusWithSort"; + /** + * 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) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITH_SORT_ENUM, + produces = { "application/json" } + ) + default ResponseEntity> findPetsWithSortEnum( + @Valid @RequestParam(value = "status", required = false) @Nullable String status, + @ValidSort(allowedValues = {"id,asc", "id,desc", "name,asc", "name,desc"}) @PageableDefault(page = 0, size = 20) final Pageable pageable + ) { + getRequest().ifPresent(request -> { + for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { + if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { + String exampleString = "[ { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" }, { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" } ]"; + ApiUtil.setExampleResponse(request, "application/json", exampleString); + break; + } + } + }); + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + + } + + + String PATH_FIND_PETS_WITHOUT_SORT_ENUM = "/pet/findWithoutSortEnum"; + /** + * GET /pet/findWithoutSortEnum : Find pets with pagination but sort has no enum constraint + * + * @return successful operation (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = PetApi.PATH_FIND_PETS_WITHOUT_SORT_ENUM, + produces = { "application/json" } + ) + default ResponseEntity> findPetsWithoutSortEnum( + @PageableDefault(page = 0, size = 20) final Pageable pageable + ) { + getRequest().ifPresent(request -> { + for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) { + if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) { + String exampleString = "[ { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" }, { \"name\" : \"name\", \"id\" : 0, \"status\" : \"status\" } ]"; + ApiUtil.setExampleResponse(request, "application/json", exampleString); + break; + } + } + }); + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + + } + +} diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApiController.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApiController.java new file mode 100644 index 000000000000..b678f64b84af --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/PetApiController.java @@ -0,0 +1,53 @@ +package org.openapitools.api; + +import org.springframework.lang.Nullable; +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.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.context.request.NativeWebRequest; + +import jakarta.validation.constraints.*; +import jakarta.validation.Valid; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import jakarta.annotation.Generated; + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2026-04-14T22:12:15.745029415Z[UTC]", comments = "Generator version: 7.22.0-SNAPSHOT") +@Controller +public class PetApiController implements PetApi { + + @Nullable + private final NativeWebRequest request; + + @Autowired + public PetApiController(@Nullable NativeWebRequest request) { + this.request = request; + } + + @Override + public Optional getRequest() { + return Optional.ofNullable(request); + } + +} diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/EnumConverterConfiguration.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/EnumConverterConfiguration.java new file mode 100644 index 000000000000..1a663e689c41 --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/EnumConverterConfiguration.java @@ -0,0 +1,29 @@ +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") +public class EnumConverterConfiguration { + + @Bean(name = "org.openapitools.configuration.EnumConverterConfiguration.petSortConverter") + Converter petSortConverter() { + return new Converter() { + @Override + public PetSort convert(String source) { + return PetSort.fromValue(source); + } + }; + } + +} diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/HomeController.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/HomeController.java new file mode 100644 index 000000000000..707313504790 --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/HomeController.java @@ -0,0 +1,13 @@ +package org.openapitools.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * Home redirection to OpenAPI api documentation + */ +@Controller +public class HomeController { + +} \ No newline at end of file diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidPageable.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidPageable.java new file mode 100644 index 000000000000..4fed6f5b26dc --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidPageable.java @@ -0,0 +1,95 @@ +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; + +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/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidSort.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidSort.java new file mode 100644 index 000000000000..abbd2f533431 --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/configuration/ValidSort.java @@ -0,0 +1,102 @@ +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; + +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/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/model/Pet.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/model/Pet.java new file mode 100644 index 000000000000..863d215509c2 --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/model/Pet.java @@ -0,0 +1,142 @@ +package org.openapitools.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import org.springframework.lang.Nullable; +import org.openapitools.jackson.nullable.JsonNullable; +import java.io.Serializable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * Pet + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2026-04-14T23:14:13.089173701Z[UTC]", comments = "Generator version: 7.22.0-SNAPSHOT") +public class Pet implements Serializable { + + private static final long serialVersionUID = 1L; + + private @Nullable Long id; + + private String name; + + private @Nullable String status; + + public Pet() { + super(); + } + + /** + * Constructor with only required parameters + */ + public Pet(String name) { + this.name = name; + } + + public Pet id(@Nullable Long id) { + this.id = id; + return this; + } + + /** + * Get id + * @return id + */ + + @JsonProperty("id") + public @Nullable Long getId() { + return id; + } + + @JsonProperty("id") + public void setId(@Nullable Long id) { + this.id = id; + } + + public Pet name(String name) { + this.name = name; + return this; + } + + /** + * Get name + * @return name + */ + @NotNull + @JsonProperty("name") + public String getName() { + return name; + } + + @JsonProperty("name") + public void setName(String name) { + this.name = name; + } + + public Pet status(@Nullable String status) { + this.status = status; + return this; + } + + /** + * pet status in the store + * @return status + */ + + @JsonProperty("status") + public @Nullable String getStatus() { + return status; + } + + @JsonProperty("status") + public void setStatus(@Nullable String status) { + this.status = status; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Pet pet = (Pet) o; + return Objects.equals(this.id, pet.id) && + Objects.equals(this.name, pet.name) && + Objects.equals(this.status, pet.status); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, status); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class Pet {\n"); + sb.append(" id: ").append(toIndentedString(id)).append("\n"); + sb.append(" name: ").append(toIndentedString(name)).append("\n"); + sb.append(" status: ").append(toIndentedString(status)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(@Nullable Object o) { + return o == null ? "null" : o.toString().replace("\n", "\n "); + } +} + diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/model/PetSort.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/model/PetSort.java new file mode 100644 index 000000000000..8cd6cd27b0ed --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/model/PetSort.java @@ -0,0 +1,60 @@ +package org.openapitools.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonValue; +import org.openapitools.jackson.nullable.JsonNullable; +import java.io.Serializable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + + +import java.util.*; +import jakarta.annotation.Generated; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Gets or Sets PetSort + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2026-04-14T23:14:13.089173701Z[UTC]", comments = "Generator version: 7.22.0-SNAPSHOT") +public enum PetSort implements Serializable { + + ID_ASC("id,asc"), + + ID_DESC("id,desc"), + + CREATED_AT_ASC("createdAt,asc"), + + CREATED_AT_DESC("createdAt,desc"); + + private final String value; + + PetSort(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @Override + public String toString() { + return String.valueOf(value); + } + + @JsonCreator + public static PetSort fromValue(String value) { + for (PetSort b : PetSort.values()) { + if (b.value.equals(value)) { + return b; + } + } + throw new IllegalArgumentException("Unexpected value '" + value + "'"); + } +} + diff --git a/samples/server/petstore/springboot-sort-validation/src/main/resources/application.properties b/samples/server/petstore/springboot-sort-validation/src/main/resources/application.properties new file mode 100644 index 000000000000..7e90813e59b2 --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/main/resources/application.properties @@ -0,0 +1,3 @@ +server.port=8080 +spring.jackson.date-format=org.openapitools.RFC3339DateFormat +spring.jackson.serialization.WRITE_DATES_AS_TIMESTAMPS=false diff --git a/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml b/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml new file mode 100644 index 000000000000..ce776ff7fa70 --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml @@ -0,0 +1,587 @@ +openapi: 3.0.1 +info: + description: Test spec for generateSortValidation feature + title: OpenAPI Petstore - Sort Validation Test + version: 1.0.0 +servers: +- url: http://petstore.swagger.io/v2 +tags: +- description: Everything about your Pets + name: pet +paths: + /pet/findByStatusWithSort: + get: + operationId: findPetsWithSortEnum + parameters: + - description: Status filter + explode: true + in: query + name: status + required: false + schema: + type: string + style: form + - explode: true + in: query + name: page + required: false + schema: + default: 0 + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + default: 20 + type: integer + style: form + - description: Sort order + explode: true + in: query + name: sort + required: false + schema: + enum: + - "id,asc" + - "id,desc" + - "name,asc" + - "name,desc" + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets with explicit x-spring-paginated and inline sort enum + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findAutoDetectedWithSort: + get: + operationId: findPetsAutoDetectedWithSort + parameters: + - description: Status filter + explode: true + in: query + name: status + required: false + schema: + type: string + style: form + - explode: true + in: query + name: page + required: false + schema: + default: 0 + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + default: 20 + type: integer + style: form + - description: Sort order + explode: true + in: query + name: sort + required: false + schema: + enum: + - "id,asc" + - "id,desc" + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets with auto-detected pagination and sort enum + tags: + - pet + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findWithRefSort: + get: + operationId: findPetsWithRefSort + parameters: + - explode: true + in: query + name: page + required: false + schema: + default: 0 + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + default: 20 + type: integer + style: form + - description: Sort order + explode: true + in: query + name: sort + required: false + schema: + $ref: "#/components/schemas/PetSort" + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets with x-spring-paginated and $ref sort enum + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findWithoutSortEnum: + get: + operationId: findPetsWithoutSortEnum + parameters: + - explode: true + in: query + name: page + required: false + schema: + default: 0 + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + default: 20 + type: integer + style: form + - description: Sort order (no enum constraint) + explode: true + in: query + name: sort + required: false + schema: + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets with pagination but sort has no enum constraint + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findNonPaginatedWithSortEnum: + get: + operationId: findPetsNonPaginatedWithSortEnum + parameters: + - description: Sort order with enum but no pagination + explode: true + in: query + name: sort + required: false + schema: + enum: + - "id,asc" + - "id,desc" + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets without pagination but sort param has enum — no sort validation + expected + tags: + - pet + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findWithSortDefaultOnly: + get: + operationId: findPetsWithSortDefaultOnly + parameters: + - explode: true + in: query + name: page + required: false + schema: + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + type: integer + style: form + - explode: true + in: query + name: sort + required: false + schema: + default: "name,desc" + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: "Find pets — sort default only (single field DESC, no page/size defaults)" + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findWithSortDefaultAsc: + get: + operationId: findPetsWithSortDefaultAsc + parameters: + - explode: true + in: query + name: page + required: false + schema: + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + type: integer + style: form + - explode: true + in: query + name: sort + required: false + schema: + default: id + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: "Find pets — sort default only (single field, no explicit direction\ + \ defaults to ASC)" + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findWithMixedSortDefaults: + get: + operationId: findPetsWithMixedSortDefaults + parameters: + - explode: true + in: query + name: page + required: false + schema: + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + type: integer + style: form + - explode: true + in: query + name: sort + required: false + schema: + default: + - "name,desc" + - "id,asc" + items: + type: string + type: array + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets — multiple sort defaults with mixed directions (array sort + param) + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findWithPageSizeDefaultsOnly: + get: + operationId: findPetsWithPageSizeDefaultsOnly + parameters: + - explode: true + in: query + name: page + required: false + schema: + default: 0 + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + default: 25 + type: integer + style: form + - explode: true + in: query + name: sort + required: false + schema: + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: "Find pets — page and size defaults only, no sort default" + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findWithAllDefaults: + get: + operationId: findPetsWithAllDefaults + parameters: + - explode: true + in: query + name: page + required: false + schema: + default: 0 + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + default: 10 + type: integer + style: form + - explode: true + in: query + name: sort + required: false + schema: + default: + - "name,desc" + - "id,asc" + items: + type: string + type: array + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: "Find pets — page, size, and mixed sort defaults all present" + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findWithSizeConstraint: + get: + operationId: findPetsWithSizeConstraint + parameters: + - explode: true + in: query + name: page + required: false + schema: + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + maximum: 100 + type: integer + style: form + - explode: true + in: query + name: sort + required: false + schema: + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets — size has maximum constraint only + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet + /pet/findWithPageAndSizeConstraint: + get: + operationId: findPetsWithPageAndSizeConstraint + parameters: + - explode: true + in: query + name: page + required: false + schema: + maximum: 999 + type: integer + style: form + - explode: true + in: query + name: size + required: false + schema: + maximum: 50 + type: integer + style: form + - explode: true + in: query + name: sort + required: false + schema: + type: string + style: form + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/Pet" + type: array + description: successful operation + summary: Find pets — both page and size have maximum constraints + tags: + - pet + x-spring-paginated: true + x-accepts: + - application/json + x-tags: + - tag: pet +components: + schemas: + PetSort: + enum: + - "id,asc" + - "id,desc" + - "createdAt,asc" + - "createdAt,desc" + type: string + Pet: + example: + name: name + id: 0 + status: status + properties: + id: + format: int64 + type: integer + name: + type: string + status: + description: pet status in the store + type: string + required: + - name + type: object diff --git a/samples/server/petstore/springboot-sort-validation/src/test/java/org/openapitools/OpenApiGeneratorApplicationTests.java b/samples/server/petstore/springboot-sort-validation/src/test/java/org/openapitools/OpenApiGeneratorApplicationTests.java new file mode 100644 index 000000000000..3681f67e7705 --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/test/java/org/openapitools/OpenApiGeneratorApplicationTests.java @@ -0,0 +1,13 @@ +package org.openapitools; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class OpenApiGeneratorApplicationTests { + + @Test + void contextLoads() { + } + +} \ No newline at end of file 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"))) {