Skip to content

Commit 473c551

Browse files
committed
feat: add sort validation support for pageable operations
1 parent 01ef44a commit 473c551

4 files changed

Lines changed: 520 additions & 0 deletions

File tree

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

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
import com.samskivert.mustache.Template;
2323
import io.swagger.v3.oas.models.OpenAPI;
2424
import io.swagger.v3.oas.models.Operation;
25+
import io.swagger.v3.oas.models.PathItem;
26+
import io.swagger.v3.oas.models.media.Schema;
27+
import io.swagger.v3.oas.models.parameters.Parameter;
2528
import lombok.Getter;
2629
import lombok.Setter;
2730
import org.openapitools.codegen.*;
@@ -98,6 +101,7 @@ public class KotlinSpringServerCodegen extends AbstractKotlinCodegen
98101
public static final String USE_REQUEST_MAPPING_ON_CONTROLLER = "useRequestMappingOnController";
99102
public static final String USE_REQUEST_MAPPING_ON_INTERFACE = "useRequestMappingOnInterface";
100103
public static final String AUTO_X_SPRING_PAGINATED = "autoXSpringPaginated";
104+
public static final String GENERATE_SORT_VALIDATION = "generateSortValidation";
101105
public static final String USE_SEALED_RESPONSE_INTERFACES = "useSealedResponseInterfaces";
102106
public static final String COMPANION_OBJECT = "companionObject";
103107

@@ -164,6 +168,7 @@ public String getDescription() {
164168
@Setter private DeclarativeInterfaceReactiveMode declarativeInterfaceReactiveMode = DeclarativeInterfaceReactiveMode.coroutines;
165169
@Setter private boolean useResponseEntity = true;
166170
@Setter private boolean autoXSpringPaginated = false;
171+
@Setter private boolean generateSortValidation = false;
167172
@Setter private boolean useSealedResponseInterfaces = false;
168173
@Setter private boolean companionObject = false;
169174

@@ -180,6 +185,9 @@ public String getDescription() {
180185
private Map<String, String> sealedInterfaceToOperationId = new HashMap<>();
181186
private boolean sealedInterfacesFileWritten = false;
182187

188+
// Map from operationId to allowed sort values for @ValidSort annotation generation
189+
private Map<String, List<String>> sortValidationEnums = new HashMap<>();
190+
183191
public KotlinSpringServerCodegen() {
184192
super();
185193

@@ -272,6 +280,7 @@ public KotlinSpringServerCodegen() {
272280
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");
273281
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");
274282
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);
283+
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);
275284
addSwitch(COMPANION_OBJECT, "Whether to generate companion objects in data classes, enabling companion extensions.", companionObject);
276285
supportedLibraries.put(SPRING_BOOT, "Spring-boot Server application.");
277286
supportedLibraries.put(SPRING_CLOUD_LIBRARY,
@@ -704,6 +713,10 @@ public void processOpts() {
704713
this.setAutoXSpringPaginated(convertPropertyToBoolean(AUTO_X_SPRING_PAGINATED));
705714
}
706715
writePropertyBack(AUTO_X_SPRING_PAGINATED, autoXSpringPaginated);
716+
if (additionalProperties.containsKey(GENERATE_SORT_VALIDATION) && library.equals(SPRING_BOOT)) {
717+
this.setGenerateSortValidation(convertPropertyToBoolean(GENERATE_SORT_VALIDATION));
718+
}
719+
writePropertyBack(GENERATE_SORT_VALIDATION, generateSortValidation);
707720
if (isUseSpringBoot3() && isUseSpringBoot4()) {
708721
throw new IllegalArgumentException("Choose between Spring Boot 3 and Spring Boot 4");
709722
}
@@ -1042,6 +1055,22 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation
10421055
}
10431056

10441057
// #8315 Remove matching Spring Data Web default query params if 'x-spring-paginated' with Pageable is used
1058+
// Before removal, capture sort enum values for @ValidSort if generateSortValidation is enabled
1059+
if (generateSortValidation && useBeanValidation && sortValidationEnums.containsKey(codegenOperation.operationId)) {
1060+
List<String> allowedSortValues = sortValidationEnums.get(codegenOperation.operationId);
1061+
String allowedValuesStr = allowedSortValues.stream()
1062+
.map(v -> "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\"")
1063+
.collect(Collectors.joining(", "));
1064+
String validSortAnnotation = "@ValidSort(allowedValues = [" + allowedValuesStr + "])";
1065+
1066+
Object existingAnnotation = codegenOperation.vendorExtensions.get("x-operation-extra-annotation");
1067+
List<String> existingAnnotations = DefaultCodegen.getObjectAsStringList(existingAnnotation);
1068+
List<String> updatedAnnotations = new ArrayList<>(existingAnnotations);
1069+
updatedAnnotations.add(validSortAnnotation);
1070+
codegenOperation.vendorExtensions.put("x-operation-extra-annotation", updatedAnnotations);
1071+
1072+
codegenOperation.imports.add("ValidSort");
1073+
}
10451074
codegenOperation.queryParams.removeIf(param -> defaultPageableQueryParams.contains(param.baseName));
10461075
codegenOperation.allParams.removeIf(param -> param.isQueryParam && defaultPageableQueryParams.contains(param.baseName));
10471076
}
@@ -1058,6 +1087,10 @@ public void preprocessOpenAPI(OpenAPI openAPI) {
10581087
(sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "EnumConverterConfiguration.kt"));
10591088
}
10601089

1090+
if (SPRING_BOOT.equals(library) && generateSortValidation && useBeanValidation) {
1091+
scanSortValidationEnums(openAPI);
1092+
}
1093+
10611094
if (!additionalProperties.containsKey(TITLE)) {
10621095
// The purpose of the title is for:
10631096
// - README documentation
@@ -1123,6 +1156,78 @@ public void preprocessOpenAPI(OpenAPI openAPI) {
11231156
// TODO: Handle tags
11241157
}
11251158

1159+
/**
1160+
* Scans the OpenAPI spec for paginated operations whose 'sort' parameter has enum values,
1161+
* builds the {@link #sortValidationEnums} registry, and registers the ValidSort.kt supporting file.
1162+
* Called from {@link #preprocessOpenAPI} when {@code generateSortValidation} is enabled.
1163+
*/
1164+
private void scanSortValidationEnums(OpenAPI openAPI) {
1165+
if (openAPI.getPaths() == null) {
1166+
return;
1167+
}
1168+
boolean foundAny = false;
1169+
for (Map.Entry<String, PathItem> pathEntry : openAPI.getPaths().entrySet()) {
1170+
for (Operation operation : pathEntry.getValue().readOperations()) {
1171+
String operationId = operation.getOperationId();
1172+
if (operationId == null || !willBePageable(operation)) {
1173+
continue;
1174+
}
1175+
if (operation.getParameters() == null) {
1176+
continue;
1177+
}
1178+
for (Parameter param : operation.getParameters()) {
1179+
if (!"sort".equals(param.getName())) {
1180+
continue;
1181+
}
1182+
Schema<?> schema = param.getSchema();
1183+
if (schema == null) {
1184+
continue;
1185+
}
1186+
if (schema.get$ref() != null) {
1187+
schema = ModelUtils.getReferencedSchema(openAPI, schema);
1188+
}
1189+
if (schema == null || schema.getEnum() == null || schema.getEnum().isEmpty()) {
1190+
continue;
1191+
}
1192+
List<String> enumValues = schema.getEnum().stream()
1193+
.map(Object::toString)
1194+
.collect(Collectors.toList());
1195+
sortValidationEnums.put(operationId, enumValues);
1196+
foundAny = true;
1197+
}
1198+
}
1199+
}
1200+
if (foundAny) {
1201+
importMapping.putIfAbsent("ValidSort", configPackage + ".ValidSort");
1202+
supportingFiles.add(new SupportingFile("validSort.mustache",
1203+
(sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidSort.kt"));
1204+
}
1205+
}
1206+
1207+
/**
1208+
* Returns true if the given operation will have a Pageable parameter injected — either because
1209+
* it has {@code x-spring-paginated: true} explicitly, or because {@link #autoXSpringPaginated}
1210+
* is enabled and the operation has all three default pagination query parameters (page, size, sort).
1211+
*/
1212+
private boolean willBePageable(Operation operation) {
1213+
if (operation.getExtensions() != null) {
1214+
Object paginated = operation.getExtensions().get("x-spring-paginated");
1215+
if (Boolean.FALSE.equals(paginated)) {
1216+
return false;
1217+
}
1218+
if (Boolean.TRUE.equals(paginated)) {
1219+
return true;
1220+
}
1221+
}
1222+
if (autoXSpringPaginated && operation.getParameters() != null) {
1223+
Set<String> paramNames = operation.getParameters().stream()
1224+
.map(Parameter::getName)
1225+
.collect(Collectors.toSet());
1226+
return paramNames.containsAll(Arrays.asList("page", "size", "sort"));
1227+
}
1228+
return false;
1229+
}
1230+
11261231
@Override
11271232
public void postProcessModelProperty(CodegenModel model, CodegenProperty property) {
11281233
super.postProcessModelProperty(model, property);
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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 {{javaxPackage}}.validation.constraintvalidation.SupportedValidationTarget
8+
import {{javaxPackage}}.validation.constraintvalidation.ValidationTarget
9+
import org.springframework.data.domain.Pageable
10+
11+
/**
12+
* Validates that sort properties in a [Pageable] parameter match the allowed values.
13+
*
14+
* This annotation can only be applied to methods that have a [Pageable] parameter.
15+
* The validator checks that each sort property and direction combination in the [Pageable]
16+
* matches one of the strings specified in [allowedValues].
17+
*
18+
* Expected value format: `"property,direction"` (e.g. `"id,asc"`, `"name,desc"`).
19+
*
20+
* @property allowedValues The allowed sort strings (e.g. `["id,asc", "id,desc"]`)
21+
* @property groups Validation groups (optional)
22+
* @property payload Additional payload (optional)
23+
* @property message Validation error message (default: "Invalid sort column")
24+
*/
25+
@MustBeDocumented
26+
@Retention(AnnotationRetention.RUNTIME)
27+
@Constraint(validatedBy = [SortValidator::class])
28+
@Target(AnnotationTarget.FUNCTION)
29+
annotation class ValidSort(
30+
val allowedValues: Array<String>,
31+
val groups: Array<kotlin.reflect.KClass<*>> = [],
32+
val payload: Array<kotlin.reflect.KClass<out Payload>> = [],
33+
val message: String = "Invalid sort column"
34+
)
35+
36+
@SupportedValidationTarget(ValidationTarget.PARAMETERS)
37+
class SortValidator : ConstraintValidator<ValidSort, Array<Any?>> {
38+
39+
private lateinit var allowedValues: Set<String>
40+
41+
override fun initialize(constraintAnnotation: ValidSort) {
42+
allowedValues = constraintAnnotation.allowedValues.toSet()
43+
}
44+
45+
override fun isValid(parameters: Array<Any?>?, context: ConstraintValidatorContext): Boolean {
46+
val pageable = parameters?.filterIsInstance<Pageable>()?.firstOrNull()
47+
?: throw IllegalStateException(
48+
"@ValidSort can only be used on methods with a Pageable parameter. " +
49+
"Ensure the annotated method has a parameter of type org.springframework.data.domain.Pageable."
50+
)
51+
52+
val invalid = pageable.sort
53+
.foldIndexed(emptyMap<Int, String>()) { index, acc, order ->
54+
val sortValue = "${order.property},${order.direction.name.lowercase()}"
55+
if (sortValue !in allowedValues) acc + (index to order.property)
56+
else acc
57+
}
58+
.toSortedMap()
59+
60+
if (invalid.isNotEmpty()) {
61+
context.disableDefaultConstraintViolation()
62+
invalid.forEach { (index, property) ->
63+
context.buildConstraintViolationWithTemplate(
64+
"${context.defaultConstraintMessageTemplate} [$property]"
65+
)
66+
.addPropertyNode("sort")
67+
.addPropertyNode("property")
68+
.inIterable()
69+
.atIndex(index)
70+
.addConstraintViolation()
71+
}
72+
}
73+
74+
return invalid.isEmpty()
75+
}
76+
}

0 commit comments

Comments
 (0)