Skip to content

Commit 767f864

Browse files
committed
feat: add validation for pageable and sort parameters with new annotations
1 parent 67884e8 commit 767f864

5 files changed

Lines changed: 571 additions & 1 deletion

File tree

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/SpringCodegen.java

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ public class SpringCodegen extends AbstractJavaCodegen
111111
public static final String JACKSON3_PACKAGE = "tools.jackson";
112112
public static final String JACKSON_PACKAGE = "jacksonPackage";
113113
public static final String ADDITIONAL_NOT_NULL_ANNOTATIONS = "additionalNotNullAnnotations";
114+
public static final String AUTO_X_SPRING_PAGINATED = "autoXSpringPaginated";
115+
public static final String GENERATE_SORT_VALIDATION = "generateSortValidation";
116+
public static final String GENERATE_PAGEABLE_CONSTRAINT_VALIDATION = "generatePageableConstraintValidation";
114117

115118
@Getter
116119
public enum RequestMappingMode {
@@ -186,6 +189,16 @@ public enum RequestMappingMode {
186189
@Getter @Setter
187190
protected boolean additionalNotNullAnnotations = false;
188191
@Setter boolean useHttpServiceProxyFactoryInterfacesConfigurator = false;
192+
@Setter protected boolean autoXSpringPaginated = false;
193+
@Setter protected boolean generateSortValidation = false;
194+
@Setter protected boolean generatePageableConstraintValidation = false;
195+
196+
// Map from operationId to allowed sort values for @ValidSort annotation generation
197+
private Map<String, List<String>> sortValidationEnums = new HashMap<>();
198+
// Map from operationId to pageable defaults for @PageableDefault/@SortDefault annotation generation
199+
private Map<String, SpringPageableScanUtils.PageableDefaultsData> pageableDefaultsRegistry = new HashMap<>();
200+
// Map from operationId to pageable constraints for @ValidPageable annotation generation
201+
private Map<String, SpringPageableScanUtils.PageableConstraintsData> pageableConstraintsRegistry = new HashMap<>();
189202

190203
public SpringCodegen() {
191204
super();
@@ -338,6 +351,21 @@ public SpringCodegen() {
338351
cliOptions.add(CliOption.newBoolean(ADDITIONAL_NOT_NULL_ANNOTATIONS,
339352
"Add @NotNull to path variables (required by default) and requestBody.",
340353
additionalNotNullAnnotations));
354+
cliOptions.add(CliOption.newBoolean(AUTO_X_SPRING_PAGINATED,
355+
"Automatically add x-spring-paginated to operations that have 'page', 'size', and 'sort' query parameters. "
356+
+ "When enabled, operations with all three parameters will have Pageable support automatically applied. "
357+
+ "Operations with x-spring-paginated explicitly set to false will not be auto-detected. "
358+
+ "Only applies when library=spring-boot.",
359+
autoXSpringPaginated));
360+
cliOptions.add(CliOption.newBoolean(GENERATE_SORT_VALIDATION,
361+
"Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to paginated operations "
362+
+ "whose 'sort' parameter has enum values. Requires useBeanValidation=true and library=spring-boot.",
363+
generateSortValidation));
364+
cliOptions.add(CliOption.newBoolean(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION,
365+
"Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to "
366+
+ "paginated operations whose 'page' or 'size' parameter has a maximum constraint. "
367+
+ "Requires useBeanValidation=true and library=spring-boot.",
368+
generatePageableConstraintValidation));
341369

342370
}
343371

@@ -547,6 +575,12 @@ public void processOpts() {
547575

548576
convertPropertyToBooleanAndWriteBack(ADDITIONAL_NOT_NULL_ANNOTATIONS, this::setAdditionalNotNullAnnotations);
549577

578+
if (SPRING_BOOT.equals(library)) {
579+
convertPropertyToBooleanAndWriteBack(AUTO_X_SPRING_PAGINATED, this::setAutoXSpringPaginated);
580+
convertPropertyToBooleanAndWriteBack(GENERATE_SORT_VALIDATION, this::setGenerateSortValidation);
581+
convertPropertyToBooleanAndWriteBack(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, this::setGeneratePageableConstraintValidation);
582+
}
583+
550584
// override parent one
551585
importMapping.put("JsonDeserialize", (useJackson3 ? JACKSON3_PACKAGE : JACKSON2_PACKAGE) + ".databind.annotation.JsonDeserialize");
552586

@@ -792,6 +826,33 @@ public void preprocessOpenAPI(OpenAPI openAPI) {
792826
(sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "EnumConverterConfiguration.java"));
793827
}
794828

829+
if (SPRING_BOOT.equals(library) && generateSortValidation && useBeanValidation) {
830+
sortValidationEnums = SpringPageableScanUtils.scanSortValidationEnums(openAPI, autoXSpringPaginated);
831+
if (!sortValidationEnums.isEmpty()) {
832+
importMapping.putIfAbsent("ValidSort", configPackage + ".ValidSort");
833+
supportingFiles.add(new SupportingFile("validSort.mustache",
834+
(sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "ValidSort.java"));
835+
}
836+
}
837+
838+
if (SPRING_BOOT.equals(library)) {
839+
pageableDefaultsRegistry = SpringPageableScanUtils.scanPageableDefaults(openAPI, autoXSpringPaginated);
840+
if (!pageableDefaultsRegistry.isEmpty()) {
841+
importMapping.putIfAbsent("PageableDefault", "org.springframework.data.web.PageableDefault");
842+
importMapping.putIfAbsent("SortDefault", "org.springframework.data.web.SortDefault");
843+
importMapping.putIfAbsent("Sort", "org.springframework.data.domain.Sort");
844+
}
845+
}
846+
847+
if (SPRING_BOOT.equals(library) && generatePageableConstraintValidation && useBeanValidation) {
848+
pageableConstraintsRegistry = SpringPageableScanUtils.scanPageableConstraints(openAPI, autoXSpringPaginated);
849+
if (!pageableConstraintsRegistry.isEmpty()) {
850+
importMapping.putIfAbsent("ValidPageable", configPackage + ".ValidPageable");
851+
supportingFiles.add(new SupportingFile("validPageable.mustache",
852+
(sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "ValidPageable.java"));
853+
}
854+
}
855+
795856
/*
796857
* TODO the following logic should not need anymore in OAS 3.0 if
797858
* ("/".equals(swagger.getBasePath())) { swagger.setBasePath(""); }
@@ -1114,6 +1175,24 @@ protected boolean isConstructorWithAllArgsAllowed(CodegenModel codegenModel) {
11141175
@Override
11151176
public CodegenOperation fromOperation(String path, String httpMethod, Operation operation, List<Server> servers) {
11161177

1178+
// Auto-detect pagination parameters and add x-spring-paginated if autoXSpringPaginated is enabled.
1179+
// Only for spring-boot; respect manual x-spring-paginated: false override.
1180+
if (SPRING_BOOT.equals(library) && autoXSpringPaginated) {
1181+
if (operation.getExtensions() == null || !Boolean.FALSE.equals(operation.getExtensions().get("x-spring-paginated"))) {
1182+
if (operation.getParameters() != null) {
1183+
Set<String> paramNames = operation.getParameters().stream()
1184+
.map(io.swagger.v3.oas.models.parameters.Parameter::getName)
1185+
.collect(Collectors.toSet());
1186+
if (paramNames.containsAll(Arrays.asList("page", "size", "sort"))) {
1187+
if (operation.getExtensions() == null) {
1188+
operation.setExtensions(new HashMap<>());
1189+
}
1190+
operation.getExtensions().put("x-spring-paginated", Boolean.TRUE);
1191+
}
1192+
}
1193+
}
1194+
}
1195+
11171196
// add Pageable import only if x-spring-paginated explicitly used
11181197
// this allows to use a custom Pageable schema without importing Spring Pageable.
11191198
if (Boolean.TRUE.equals(operation.getExtensions().get("x-spring-paginated"))) {
@@ -1142,6 +1221,56 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation
11421221
// #8315 Remove matching Spring Data Web default query params if 'x-spring-paginated' with Pageable is used
11431222
codegenOperation.queryParams.removeIf(param -> defaultPageableQueryParams.contains(param.baseName));
11441223
codegenOperation.allParams.removeIf(param -> param.isQueryParam && defaultPageableQueryParams.contains(param.baseName));
1224+
1225+
// Build pageable parameter annotations (@ValidPageable, @ValidSort, @PageableDefault, @SortDefault.SortDefaults)
1226+
List<String> pageableAnnotations = new ArrayList<>();
1227+
1228+
if (generatePageableConstraintValidation && useBeanValidation && pageableConstraintsRegistry.containsKey(codegenOperation.operationId)) {
1229+
SpringPageableScanUtils.PageableConstraintsData constraints = pageableConstraintsRegistry.get(codegenOperation.operationId);
1230+
List<String> attrs = new ArrayList<>();
1231+
if (constraints.maxSize >= 0) attrs.add("maxSize = " + constraints.maxSize);
1232+
if (constraints.maxPage >= 0) attrs.add("maxPage = " + constraints.maxPage);
1233+
pageableAnnotations.add("@ValidPageable(" + String.join(", ", attrs) + ")");
1234+
codegenOperation.imports.add("ValidPageable");
1235+
}
1236+
1237+
if (generateSortValidation && useBeanValidation && sortValidationEnums.containsKey(codegenOperation.operationId)) {
1238+
List<String> allowedSortValues = sortValidationEnums.get(codegenOperation.operationId);
1239+
// Java annotation arrays use {} syntax
1240+
String allowedValuesStr = allowedSortValues.stream()
1241+
.map(v -> "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\"")
1242+
.collect(Collectors.joining(", "));
1243+
pageableAnnotations.add("@ValidSort(allowedValues = {" + allowedValuesStr + "})");
1244+
codegenOperation.imports.add("ValidSort");
1245+
}
1246+
1247+
if (pageableDefaultsRegistry.containsKey(codegenOperation.operationId)) {
1248+
SpringPageableScanUtils.PageableDefaultsData defaults = pageableDefaultsRegistry.get(codegenOperation.operationId);
1249+
if (defaults.page != null || defaults.size != null) {
1250+
List<String> attrs = new ArrayList<>();
1251+
if (defaults.page != null) attrs.add("page = " + defaults.page);
1252+
if (defaults.size != null) attrs.add("size = " + defaults.size);
1253+
pageableAnnotations.add("@PageableDefault(" + String.join(", ", attrs) + ")");
1254+
codegenOperation.imports.add("PageableDefault");
1255+
}
1256+
if (!defaults.sortDefaults.isEmpty()) {
1257+
// Java annotation arrays use @SortDefault(...) with {} for the sort field array
1258+
List<String> sortEntries = defaults.sortDefaults.stream()
1259+
.map(sf -> "@SortDefault(sort = {\"" + sf.field + "\"}, direction = Sort.Direction." + sf.direction + ")")
1260+
.collect(Collectors.toList());
1261+
if (sortEntries.size() == 1) {
1262+
pageableAnnotations.add("@SortDefault.SortDefaults(" + sortEntries.get(0) + ")");
1263+
} else {
1264+
pageableAnnotations.add("@SortDefault.SortDefaults({" + String.join(", ", sortEntries) + "})");
1265+
}
1266+
codegenOperation.imports.add("SortDefault");
1267+
codegenOperation.imports.add("Sort");
1268+
}
1269+
}
1270+
1271+
if (!pageableAnnotations.isEmpty()) {
1272+
codegenOperation.vendorExtensions.put("x-pageable-extra-annotation", pageableAnnotations);
1273+
}
11451274
}
11461275
if (codegenOperation.vendorExtensions.containsKey("x-spring-provide-args") && !provideArgsClassSet.isEmpty()) {
11471276
codegenOperation.imports.addAll(provideArgsClassSet);

modules/openapi-generator/src/main/resources/JavaSpring/api.mustache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ public interface {{classname}} {
279279
{{#allParams}}{{>queryParams}}{{>pathParams}}{{>headerParams}}{{>bodyParams}}{{>formParams}}{{>cookieParams}}{{^-last}},
280280
{{/-last}}{{/allParams}}{{#includeHttpRequestContext}}{{#hasParams}},
281281
{{/hasParams}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true){{/swagger2AnnotationLibrary}} final {{#reactive}}ServerWebExchange exchange{{/reactive}}{{^reactive}}HttpServletRequest servletRequest{{/reactive}}{{/includeHttpRequestContext}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}},
282-
{{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}},{{/includeHttpRequestContext}}{{/hasParams}}{{#springDocDocumentationProvider}}@ParameterObject{{/springDocDocumentationProvider}} final Pageable pageable{{/vendorExtensions.x-spring-paginated}}{{#vendorExtensions.x-spring-provide-args}}{{#hasParams}},
282+
{{/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}},
283283
{{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}},{{/includeHttpRequestContext}}{{/hasParams}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true){{/swagger2AnnotationLibrary}} {{{.}}}{{^hasParams}}{{^-last}}{{^reactive}},{{/reactive}}
284284
{{/-last}}{{/hasParams}}{{/vendorExtensions.x-spring-provide-args}}
285285
){{#unhandledException}} throws Exception{{/unhandledException}}{{^jdk8-default-interface}};{{/jdk8-default-interface}}{{#jdk8-default-interface}} {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package {{configPackage}};
2+
3+
import {{javaxPackage}}.validation.Constraint;
4+
import {{javaxPackage}}.validation.ConstraintValidator;
5+
import {{javaxPackage}}.validation.ConstraintValidatorContext;
6+
import {{javaxPackage}}.validation.Payload;
7+
import org.springframework.data.domain.Pageable;
8+
9+
import java.lang.annotation.Documented;
10+
import java.lang.annotation.ElementType;
11+
import java.lang.annotation.Retention;
12+
import java.lang.annotation.RetentionPolicy;
13+
import java.lang.annotation.Target;
14+
15+
/**
16+
* Validates that the page number and page size in the annotated {@link Pageable} parameter do not
17+
* exceed their configured maximums.
18+
*
19+
* <p>Apply directly on a {@code Pageable} parameter. Each attribute is independently optional:
20+
* <ul>
21+
* <li>{@link #maxSize()} — when set (&gt;= 0), validates {@code pageable.getPageSize() <= maxSize}
22+
* <li>{@link #maxPage()} — when set (&gt;= 0), validates {@code pageable.getPageNumber() <= maxPage}
23+
* </ul>
24+
*
25+
* <p>Use {@link #NO_LIMIT} (= {@code -1}, the default) to leave an attribute unconstrained.
26+
*
27+
* <p>Constraining {@link #maxPage()} is useful to prevent deep-pagination attacks, where a large
28+
* page offset (e.g. {@code ?page=100000&size=20}) causes an expensive {@code OFFSET} query on the
29+
* database.
30+
*/
31+
@Documented
32+
@Retention(RetentionPolicy.RUNTIME)
33+
@Constraint(validatedBy = {ValidPageable.PageableConstraintValidator.class})
34+
@Target({ElementType.PARAMETER})
35+
public @interface ValidPageable {
36+
37+
/** Sentinel value meaning no limit is applied. */
38+
int NO_LIMIT = -1;
39+
40+
/** Maximum allowed page size, or {@link #NO_LIMIT} if unconstrained. */
41+
int maxSize() default NO_LIMIT;
42+
43+
/** Maximum allowed page number (0-based), or {@link #NO_LIMIT} if unconstrained. */
44+
int maxPage() default NO_LIMIT;
45+
46+
Class<?>[] groups() default {};
47+
48+
Class<? extends Payload>[] payload() default {};
49+
50+
String message() default "Invalid page request";
51+
52+
class PageableConstraintValidator implements ConstraintValidator<ValidPageable, Pageable> {
53+
54+
private int maxSize = NO_LIMIT;
55+
private int maxPage = NO_LIMIT;
56+
57+
@Override
58+
public void initialize(ValidPageable constraintAnnotation) {
59+
maxSize = constraintAnnotation.maxSize();
60+
maxPage = constraintAnnotation.maxPage();
61+
}
62+
63+
@Override
64+
public boolean isValid(Pageable pageable, ConstraintValidatorContext context) {
65+
if (pageable == null) {
66+
return true;
67+
}
68+
69+
boolean valid = true;
70+
context.disableDefaultConstraintViolation();
71+
72+
if (maxSize >= 0 && pageable.getPageSize() > maxSize) {
73+
context.buildConstraintViolationWithTemplate(
74+
context.getDefaultConstraintMessageTemplate()
75+
+ ": page size " + pageable.getPageSize()
76+
+ " exceeds maximum " + maxSize)
77+
.addPropertyNode("size")
78+
.addConstraintViolation();
79+
valid = false;
80+
}
81+
82+
if (maxPage >= 0 && pageable.getPageNumber() > maxPage) {
83+
context.buildConstraintViolationWithTemplate(
84+
context.getDefaultConstraintMessageTemplate()
85+
+ ": page number " + pageable.getPageNumber()
86+
+ " exceeds maximum " + maxPage)
87+
.addPropertyNode("page")
88+
.addConstraintViolation();
89+
valid = false;
90+
}
91+
92+
return valid;
93+
}
94+
}
95+
}

0 commit comments

Comments
 (0)