Skip to content

Commit 9af137f

Browse files
committed
feat: add pageable constraint validation tests for size and page limits
1 parent 919ee18 commit 9af137f

5 files changed

Lines changed: 588 additions & 182 deletions

File tree

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

Lines changed: 46 additions & 182 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
package org.openapitools.codegen.languages;
1818

1919
import com.google.common.collect.ImmutableMap;
20-
import com.fasterxml.jackson.databind.node.ArrayNode;
2120
import com.samskivert.mustache.Mustache;
2221
import com.samskivert.mustache.Mustache.Lambda;
2322
import com.samskivert.mustache.Template;
@@ -103,6 +102,7 @@ public class KotlinSpringServerCodegen extends AbstractKotlinCodegen
103102
public static final String USE_REQUEST_MAPPING_ON_INTERFACE = "useRequestMappingOnInterface";
104103
public static final String AUTO_X_SPRING_PAGINATED = "autoXSpringPaginated";
105104
public static final String GENERATE_SORT_VALIDATION = "generateSortValidation";
105+
public static final String GENERATE_PAGEABLE_CONSTRAINT_VALIDATION = "generatePageableConstraintValidation";
106106
public static final String USE_SEALED_RESPONSE_INTERFACES = "useSealedResponseInterfaces";
107107
public static final String COMPANION_OBJECT = "companionObject";
108108

@@ -170,6 +170,7 @@ public String getDescription() {
170170
@Setter private boolean useResponseEntity = true;
171171
@Setter private boolean autoXSpringPaginated = false;
172172
@Setter private boolean generateSortValidation = false;
173+
@Setter private boolean generatePageableConstraintValidation = false;
173174
@Setter private boolean useSealedResponseInterfaces = false;
174175
@Setter private boolean companionObject = false;
175176

@@ -190,7 +191,10 @@ public String getDescription() {
190191
private Map<String, List<String>> sortValidationEnums = new HashMap<>();
191192

192193
// Map from operationId to pageable defaults for @PageableDefault/@SortDefault annotation generation
193-
private Map<String, PageableDefaultsData> pageableDefaultsRegistry = new HashMap<>();
194+
private Map<String, SpringPageableScanUtils.PageableDefaultsData> pageableDefaultsRegistry = new HashMap<>();
195+
196+
// Map from operationId to pageable constraints for @ValidPageable annotation generation
197+
private Map<String, SpringPageableScanUtils.PageableConstraintsData> pageableConstraintsRegistry = new HashMap<>();
194198

195199
public KotlinSpringServerCodegen() {
196200
super();
@@ -285,6 +289,7 @@ public KotlinSpringServerCodegen() {
285289
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");
286290
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);
287291
addSwitch(GENERATE_SORT_VALIDATION, "Generate a @ValidSort annotation and SortValidator class, and apply @ValidSort to paginated operations whose 'sort' parameter has enum values. Requires useBeanValidation=true and library=spring-boot.", generateSortValidation);
292+
addSwitch(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, "Generate a @ValidPageable annotation and PageableConstraintValidator class, and apply @ValidPageable to paginated operations whose 'page' or 'size' parameter has a maximum constraint. Requires useBeanValidation=true and library=spring-boot.", generatePageableConstraintValidation);
288293
addSwitch(COMPANION_OBJECT, "Whether to generate companion objects in data classes, enabling companion extensions.", companionObject);
289294
supportedLibraries.put(SPRING_BOOT, "Spring-boot Server application.");
290295
supportedLibraries.put(SPRING_CLOUD_LIBRARY,
@@ -721,6 +726,10 @@ public void processOpts() {
721726
this.setGenerateSortValidation(convertPropertyToBoolean(GENERATE_SORT_VALIDATION));
722727
}
723728
writePropertyBack(GENERATE_SORT_VALIDATION, generateSortValidation);
729+
if (additionalProperties.containsKey(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION) && library.equals(SPRING_BOOT)) {
730+
this.setGeneratePageableConstraintValidation(convertPropertyToBoolean(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION));
731+
}
732+
writePropertyBack(GENERATE_PAGEABLE_CONSTRAINT_VALIDATION, generatePageableConstraintValidation);
724733
if (isUseSpringBoot3() && isUseSpringBoot4()) {
725734
throw new IllegalArgumentException("Choose between Spring Boot 3 and Spring Boot 4");
726735
}
@@ -1059,9 +1068,18 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation
10591068
}
10601069

10611070
// #8315 Remove matching Spring Data Web default query params if 'x-spring-paginated' with Pageable is used
1062-
// Build pageable parameter annotations (@ValidSort, @PageableDefault, @SortDefault.SortDefaults)
1071+
// Build pageable parameter annotations (@ValidPageable, @ValidSort, @PageableDefault, @SortDefault.SortDefaults)
10631072
List<String> pageableAnnotations = new ArrayList<>();
10641073

1074+
if (generatePageableConstraintValidation && useBeanValidation && pageableConstraintsRegistry.containsKey(codegenOperation.operationId)) {
1075+
SpringPageableScanUtils.PageableConstraintsData constraints = pageableConstraintsRegistry.get(codegenOperation.operationId);
1076+
List<String> attrs = new ArrayList<>();
1077+
if (constraints.maxSize >= 0) attrs.add("maxSize = " + constraints.maxSize);
1078+
if (constraints.maxPage >= 0) attrs.add("maxPage = " + constraints.maxPage);
1079+
pageableAnnotations.add("@ValidPageable(" + String.join(", ", attrs) + ")");
1080+
codegenOperation.imports.add("ValidPageable");
1081+
}
1082+
10651083
if (generateSortValidation && useBeanValidation && sortValidationEnums.containsKey(codegenOperation.operationId)) {
10661084
List<String> allowedSortValues = sortValidationEnums.get(codegenOperation.operationId);
10671085
String allowedValuesStr = allowedSortValues.stream()
@@ -1073,7 +1091,7 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation
10731091

10741092
// Generate @PageableDefault / @SortDefault.SortDefaults annotations if defaults are present
10751093
if (pageableDefaultsRegistry.containsKey(codegenOperation.operationId)) {
1076-
PageableDefaultsData defaults = pageableDefaultsRegistry.get(codegenOperation.operationId);
1094+
SpringPageableScanUtils.PageableDefaultsData defaults = pageableDefaultsRegistry.get(codegenOperation.operationId);
10771095

10781096
if (defaults.page != null || defaults.size != null) {
10791097
List<String> attrs = new ArrayList<>();
@@ -1113,11 +1131,30 @@ public void preprocessOpenAPI(OpenAPI openAPI) {
11131131
}
11141132

11151133
if (SPRING_BOOT.equals(library) && generateSortValidation && useBeanValidation) {
1116-
scanSortValidationEnums(openAPI);
1134+
sortValidationEnums = SpringPageableScanUtils.scanSortValidationEnums(openAPI, autoXSpringPaginated);
1135+
if (!sortValidationEnums.isEmpty()) {
1136+
importMapping.putIfAbsent("ValidSort", configPackage + ".ValidSort");
1137+
supportingFiles.add(new SupportingFile("validSort.mustache",
1138+
(sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidSort.kt"));
1139+
}
11171140
}
11181141

11191142
if (SPRING_BOOT.equals(library)) {
1120-
scanPageableDefaults(openAPI);
1143+
pageableDefaultsRegistry = SpringPageableScanUtils.scanPageableDefaults(openAPI, autoXSpringPaginated);
1144+
if (!pageableDefaultsRegistry.isEmpty()) {
1145+
importMapping.putIfAbsent("PageableDefault", "org.springframework.data.web.PageableDefault");
1146+
importMapping.putIfAbsent("SortDefault", "org.springframework.data.web.SortDefault");
1147+
importMapping.putIfAbsent("Sort", "org.springframework.data.domain.Sort");
1148+
}
1149+
}
1150+
1151+
if (SPRING_BOOT.equals(library) && generatePageableConstraintValidation && useBeanValidation) {
1152+
pageableConstraintsRegistry = SpringPageableScanUtils.scanPageableConstraints(openAPI, autoXSpringPaginated);
1153+
if (!pageableConstraintsRegistry.isEmpty()) {
1154+
importMapping.putIfAbsent("ValidPageable", configPackage + ".ValidPageable");
1155+
supportingFiles.add(new SupportingFile("validPageable.mustache",
1156+
(sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidPageable.kt"));
1157+
}
11211158
}
11221159

11231160
if (!additionalProperties.containsKey(TITLE)) {
@@ -1186,184 +1223,11 @@ public void preprocessOpenAPI(OpenAPI openAPI) {
11861223
}
11871224

11881225
/**
1189-
* Scans the OpenAPI spec for pageable operations whose page/size/sort parameters have default values,
1190-
* builds the {@link #pageableDefaultsRegistry}, and registers required import mappings.
1191-
* Called from {@link #preprocessOpenAPI} for all spring-boot generations.
1192-
*/
1193-
private void scanPageableDefaults(OpenAPI openAPI) {
1194-
if (openAPI.getPaths() == null) {
1195-
return;
1196-
}
1197-
for (Map.Entry<String, PathItem> pathEntry : openAPI.getPaths().entrySet()) {
1198-
for (Operation operation : pathEntry.getValue().readOperations()) {
1199-
String operationId = operation.getOperationId();
1200-
if (operationId == null || !willBePageable(operation)) {
1201-
continue;
1202-
}
1203-
if (operation.getParameters() == null) {
1204-
continue;
1205-
}
1206-
Integer pageDefault = null;
1207-
Integer sizeDefault = null;
1208-
List<SortFieldDefault> sortDefaults = new ArrayList<>();
1209-
1210-
for (Parameter param : operation.getParameters()) {
1211-
Schema<?> schema = param.getSchema();
1212-
if (schema == null) {
1213-
continue;
1214-
}
1215-
if (schema.get$ref() != null) {
1216-
schema = ModelUtils.getReferencedSchema(openAPI, schema);
1217-
}
1218-
if (schema == null || schema.getDefault() == null) {
1219-
continue;
1220-
}
1221-
Object defaultValue = schema.getDefault();
1222-
switch (param.getName()) {
1223-
case "page":
1224-
if (defaultValue instanceof Number) {
1225-
pageDefault = ((Number) defaultValue).intValue();
1226-
}
1227-
break;
1228-
case "size":
1229-
if (defaultValue instanceof Number) {
1230-
sizeDefault = ((Number) defaultValue).intValue();
1231-
}
1232-
break;
1233-
case "sort":
1234-
List<String> sortValues = new ArrayList<>();
1235-
if (defaultValue instanceof String) {
1236-
sortValues.add((String) defaultValue);
1237-
} else if (defaultValue instanceof ArrayNode) {
1238-
((ArrayNode) defaultValue).forEach(node -> sortValues.add(node.asText()));
1239-
} else if (defaultValue instanceof List) {
1240-
for (Object item : (List<?>) defaultValue) {
1241-
sortValues.add(item.toString());
1242-
}
1243-
}
1244-
for (String sortStr : sortValues) {
1245-
String[] parts = sortStr.split(",", 2);
1246-
String field = parts[0].trim();
1247-
String direction = parts.length > 1 ? parts[1].trim().toUpperCase(Locale.ROOT) : "ASC";
1248-
sortDefaults.add(new SortFieldDefault(field, direction));
1249-
}
1250-
break;
1251-
default:
1252-
break;
1253-
}
1254-
}
1255-
1256-
PageableDefaultsData data = new PageableDefaultsData(pageDefault, sizeDefault, sortDefaults);
1257-
if (data.hasAny()) {
1258-
pageableDefaultsRegistry.put(operationId, data);
1259-
}
1260-
}
1261-
}
1262-
if (!pageableDefaultsRegistry.isEmpty()) {
1263-
importMapping.putIfAbsent("PageableDefault", "org.springframework.data.web.PageableDefault");
1264-
importMapping.putIfAbsent("SortDefault", "org.springframework.data.web.SortDefault");
1265-
importMapping.putIfAbsent("Sort", "org.springframework.data.domain.Sort");
1266-
}
1267-
}
1268-
1269-
/** Carries a parsed sort field and its direction (always "ASC" or "DESC") from the spec default. */
1270-
private static final class SortFieldDefault {
1271-
final String field;
1272-
final String direction;
1273-
1274-
SortFieldDefault(String field, String direction) {
1275-
this.field = field;
1276-
this.direction = direction;
1277-
}
1278-
}
1279-
1280-
/** Carries parsed default values for page, size, and sort fields from a pageable operation. */
1281-
private static final class PageableDefaultsData {
1282-
final Integer page;
1283-
final Integer size;
1284-
final List<SortFieldDefault> sortDefaults;
1285-
1286-
PageableDefaultsData(Integer page, Integer size, List<SortFieldDefault> sortDefaults) {
1287-
this.page = page;
1288-
this.size = size;
1289-
this.sortDefaults = sortDefaults;
1290-
}
1291-
1292-
boolean hasAny() {
1293-
return page != null || size != null || !sortDefaults.isEmpty();
1294-
}
1295-
}
1296-
1297-
/**
1298-
* Scans the OpenAPI spec for paginated operations whose 'sort' parameter has enum values,
1299-
* builds the {@link #sortValidationEnums} registry, and registers the ValidSort.kt supporting file.
1300-
* Called from {@link #preprocessOpenAPI} when {@code generateSortValidation} is enabled.
1301-
*/
1302-
private void scanSortValidationEnums(OpenAPI openAPI) {
1303-
if (openAPI.getPaths() == null) {
1304-
return;
1305-
}
1306-
boolean foundAny = false;
1307-
for (Map.Entry<String, PathItem> pathEntry : openAPI.getPaths().entrySet()) {
1308-
for (Operation operation : pathEntry.getValue().readOperations()) {
1309-
String operationId = operation.getOperationId();
1310-
if (operationId == null || !willBePageable(operation)) {
1311-
continue;
1312-
}
1313-
if (operation.getParameters() == null) {
1314-
continue;
1315-
}
1316-
for (Parameter param : operation.getParameters()) {
1317-
if (!"sort".equals(param.getName())) {
1318-
continue;
1319-
}
1320-
Schema<?> schema = param.getSchema();
1321-
if (schema == null) {
1322-
continue;
1323-
}
1324-
if (schema.get$ref() != null) {
1325-
schema = ModelUtils.getReferencedSchema(openAPI, schema);
1326-
}
1327-
if (schema == null || schema.getEnum() == null || schema.getEnum().isEmpty()) {
1328-
continue;
1329-
}
1330-
List<String> enumValues = schema.getEnum().stream()
1331-
.map(Object::toString)
1332-
.collect(Collectors.toList());
1333-
sortValidationEnums.put(operationId, enumValues);
1334-
foundAny = true;
1335-
}
1336-
}
1337-
}
1338-
if (foundAny) {
1339-
importMapping.putIfAbsent("ValidSort", configPackage + ".ValidSort");
1340-
supportingFiles.add(new SupportingFile("validSort.mustache",
1341-
(sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidSort.kt"));
1342-
}
1343-
}
1344-
1345-
/**
1346-
* Returns true if the given operation will have a Pageable parameter injected — either because
1347-
* it has {@code x-spring-paginated: true} explicitly, or because {@link #autoXSpringPaginated}
1348-
* is enabled and the operation has all three default pagination query parameters (page, size, sort).
1226+
* Returns true if the given operation will have a Pageable parameter injected.
1227+
* Delegates to {@link SpringPageableScanUtils#willBePageable}.
13491228
*/
13501229
private boolean willBePageable(Operation operation) {
1351-
if (operation.getExtensions() != null) {
1352-
Object paginated = operation.getExtensions().get("x-spring-paginated");
1353-
if (Boolean.FALSE.equals(paginated)) {
1354-
return false;
1355-
}
1356-
if (Boolean.TRUE.equals(paginated)) {
1357-
return true;
1358-
}
1359-
}
1360-
if (autoXSpringPaginated && operation.getParameters() != null) {
1361-
Set<String> paramNames = operation.getParameters().stream()
1362-
.map(Parameter::getName)
1363-
.collect(Collectors.toSet());
1364-
return paramNames.containsAll(Arrays.asList("page", "size", "sort"));
1365-
}
1366-
return false;
1230+
return SpringPageableScanUtils.willBePageable(operation, autoXSpringPaginated);
13671231
}
13681232

13691233
@Override

0 commit comments

Comments
 (0)