Skip to content

Commit 5981639

Browse files
committed
feat: add pageable defaults validation for pageable operations
1 parent 033bbb1 commit 5981639

5 files changed

Lines changed: 419 additions & 2 deletions

File tree

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

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

1919
import com.google.common.collect.ImmutableMap;
20+
import com.fasterxml.jackson.databind.node.ArrayNode;
2021
import com.samskivert.mustache.Mustache;
2122
import com.samskivert.mustache.Mustache.Lambda;
2223
import com.samskivert.mustache.Template;
@@ -188,6 +189,9 @@ public String getDescription() {
188189
// Map from operationId to allowed sort values for @ValidSort annotation generation
189190
private Map<String, List<String>> sortValidationEnums = new HashMap<>();
190191

192+
// Map from operationId to pageable defaults for @PageableDefault/@SortDefault annotation generation
193+
private Map<String, PageableDefaultsData> pageableDefaultsRegistry = new HashMap<>();
194+
191195
public KotlinSpringServerCodegen() {
192196
super();
193197

@@ -1071,6 +1075,33 @@ public CodegenOperation fromOperation(String path, String httpMethod, Operation
10711075

10721076
codegenOperation.imports.add("ValidSort");
10731077
}
1078+
1079+
// Generate @PageableDefault / @SortDefault.SortDefaults annotations if defaults are present
1080+
if (pageableDefaultsRegistry.containsKey(codegenOperation.operationId)) {
1081+
PageableDefaultsData defaults = pageableDefaultsRegistry.get(codegenOperation.operationId);
1082+
List<String> pageableAnnotations = new ArrayList<>();
1083+
1084+
if (defaults.page != null || defaults.size != null) {
1085+
List<String> attrs = new ArrayList<>();
1086+
if (defaults.page != null) attrs.add("page = " + defaults.page);
1087+
if (defaults.size != null) attrs.add("size = " + defaults.size);
1088+
pageableAnnotations.add("@PageableDefault(" + String.join(", ", attrs) + ")");
1089+
codegenOperation.imports.add("PageableDefault");
1090+
}
1091+
1092+
if (!defaults.sortDefaults.isEmpty()) {
1093+
List<String> sortEntries = defaults.sortDefaults.stream()
1094+
.map(sf -> "SortDefault(sort = [\"" + sf.field + "\"], direction = Sort.Direction." + sf.direction + ")")
1095+
.collect(Collectors.toList());
1096+
pageableAnnotations.add("@SortDefault.SortDefaults(" + String.join(", ", sortEntries) + ")");
1097+
codegenOperation.imports.add("SortDefault");
1098+
codegenOperation.imports.add("Sort");
1099+
}
1100+
1101+
if (!pageableAnnotations.isEmpty()) {
1102+
codegenOperation.vendorExtensions.put("x-pageable-extra-annotation", pageableAnnotations);
1103+
}
1104+
}
10741105
codegenOperation.queryParams.removeIf(param -> defaultPageableQueryParams.contains(param.baseName));
10751106
codegenOperation.allParams.removeIf(param -> param.isQueryParam && defaultPageableQueryParams.contains(param.baseName));
10761107
}
@@ -1091,6 +1122,10 @@ public void preprocessOpenAPI(OpenAPI openAPI) {
10911122
scanSortValidationEnums(openAPI);
10921123
}
10931124

1125+
if (SPRING_BOOT.equals(library)) {
1126+
scanPageableDefaults(openAPI);
1127+
}
1128+
10941129
if (!additionalProperties.containsKey(TITLE)) {
10951130
// The purpose of the title is for:
10961131
// - README documentation
@@ -1156,6 +1191,115 @@ public void preprocessOpenAPI(OpenAPI openAPI) {
11561191
// TODO: Handle tags
11571192
}
11581193

1194+
/**
1195+
* Scans the OpenAPI spec for pageable operations whose page/size/sort parameters have default values,
1196+
* builds the {@link #pageableDefaultsRegistry}, and registers required import mappings.
1197+
* Called from {@link #preprocessOpenAPI} for all spring-boot generations.
1198+
*/
1199+
private void scanPageableDefaults(OpenAPI openAPI) {
1200+
if (openAPI.getPaths() == null) {
1201+
return;
1202+
}
1203+
for (Map.Entry<String, PathItem> pathEntry : openAPI.getPaths().entrySet()) {
1204+
for (Operation operation : pathEntry.getValue().readOperations()) {
1205+
String operationId = operation.getOperationId();
1206+
if (operationId == null || !willBePageable(operation)) {
1207+
continue;
1208+
}
1209+
if (operation.getParameters() == null) {
1210+
continue;
1211+
}
1212+
Integer pageDefault = null;
1213+
Integer sizeDefault = null;
1214+
List<SortFieldDefault> sortDefaults = new ArrayList<>();
1215+
1216+
for (Parameter param : operation.getParameters()) {
1217+
Schema<?> schema = param.getSchema();
1218+
if (schema == null) {
1219+
continue;
1220+
}
1221+
if (schema.get$ref() != null) {
1222+
schema = ModelUtils.getReferencedSchema(openAPI, schema);
1223+
}
1224+
if (schema == null || schema.getDefault() == null) {
1225+
continue;
1226+
}
1227+
Object defaultValue = schema.getDefault();
1228+
switch (param.getName()) {
1229+
case "page":
1230+
if (defaultValue instanceof Number) {
1231+
pageDefault = ((Number) defaultValue).intValue();
1232+
}
1233+
break;
1234+
case "size":
1235+
if (defaultValue instanceof Number) {
1236+
sizeDefault = ((Number) defaultValue).intValue();
1237+
}
1238+
break;
1239+
case "sort":
1240+
List<String> sortValues = new ArrayList<>();
1241+
if (defaultValue instanceof String) {
1242+
sortValues.add((String) defaultValue);
1243+
} else if (defaultValue instanceof ArrayNode) {
1244+
((ArrayNode) defaultValue).forEach(node -> sortValues.add(node.asText()));
1245+
} else if (defaultValue instanceof List) {
1246+
for (Object item : (List<?>) defaultValue) {
1247+
sortValues.add(item.toString());
1248+
}
1249+
}
1250+
for (String sortStr : sortValues) {
1251+
String[] parts = sortStr.split(",", 2);
1252+
String field = parts[0].trim();
1253+
String direction = parts.length > 1 ? parts[1].trim().toUpperCase(Locale.ROOT) : "ASC";
1254+
sortDefaults.add(new SortFieldDefault(field, direction));
1255+
}
1256+
break;
1257+
default:
1258+
break;
1259+
}
1260+
}
1261+
1262+
PageableDefaultsData data = new PageableDefaultsData(pageDefault, sizeDefault, sortDefaults);
1263+
if (data.hasAny()) {
1264+
pageableDefaultsRegistry.put(operationId, data);
1265+
}
1266+
}
1267+
}
1268+
if (!pageableDefaultsRegistry.isEmpty()) {
1269+
importMapping.putIfAbsent("PageableDefault", "org.springframework.data.web.PageableDefault");
1270+
importMapping.putIfAbsent("SortDefault", "org.springframework.data.web.SortDefault");
1271+
importMapping.putIfAbsent("Sort", "org.springframework.data.domain.Sort");
1272+
}
1273+
}
1274+
1275+
/** Carries a parsed sort field and its direction (always "ASC" or "DESC") from the spec default. */
1276+
private static final class SortFieldDefault {
1277+
final String field;
1278+
final String direction;
1279+
1280+
SortFieldDefault(String field, String direction) {
1281+
this.field = field;
1282+
this.direction = direction;
1283+
}
1284+
}
1285+
1286+
/** Carries parsed default values for page, size, and sort fields from a pageable operation. */
1287+
private static final class PageableDefaultsData {
1288+
final Integer page;
1289+
final Integer size;
1290+
final List<SortFieldDefault> sortDefaults;
1291+
1292+
PageableDefaultsData(Integer page, Integer size, List<SortFieldDefault> sortDefaults) {
1293+
this.page = page;
1294+
this.size = size;
1295+
this.sortDefaults = sortDefaults;
1296+
}
1297+
1298+
boolean hasAny() {
1299+
return page != null || size != null || !sortDefaults.isEmpty();
1300+
}
1301+
}
1302+
11591303
/**
11601304
* Scans the OpenAPI spec for paginated operations whose 'sort' parameter has enum values,
11611305
* builds the {@link #sortValidationEnums} registry, and registers the ValidSort.kt supporting file.

modules/openapi-generator/src/main/resources/kotlin-spring/api.mustache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ class {{classname}}Controller({{#serviceInterface}}@Autowired(required = true) v
110110
{{>queryParams}}{{>pathParams}}{{>headerParams}}{{>cookieParams}}{{>bodyParams}}{{>formParams}}{{^-last}},{{/-last}}{{/allParams}}{{#includeHttpRequestContext}}{{#hasParams}},
111111
{{/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}},
112112
{{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}},
113-
{{/includeHttpRequestContext}}{{/hasParams}}{{#swagger1AnnotationLibrary}}@ApiParam(hidden = true) {{/swagger1AnnotationLibrary}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true) {{/swagger2AnnotationLibrary}}pageable: Pageable{{/vendorExtensions.x-spring-paginated}}{{#hasParams}}
113+
{{/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}}
114114
{{/hasParams}}): {{#useResponseEntity}}ResponseEntity<{{/useResponseEntity}}{{>returnTypes}}{{#useResponseEntity}}>{{/useResponseEntity}} {
115115
return {{>returnValue}}
116116
}

modules/openapi-generator/src/main/resources/kotlin-spring/apiInterface.mustache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ interface {{classname}} {
125125
{{>queryParams}}{{>pathParams}}{{>headerParams}}{{>cookieParams}}{{>bodyParams}}{{>formParams}}{{^-last}},{{/-last}}{{/allParams}}{{#includeHttpRequestContext}}{{#hasParams}},
126126
{{/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}},
127127
{{/hasParams}}{{^hasParams}}{{#includeHttpRequestContext}},
128-
{{/includeHttpRequestContext}}{{/hasParams}}{{#swagger1AnnotationLibrary}}@ApiParam(hidden = true) {{/swagger1AnnotationLibrary}}{{#swagger2AnnotationLibrary}}@Parameter(hidden = true) {{/swagger2AnnotationLibrary}}pageable: Pageable{{/vendorExtensions.x-spring-paginated}}{{#hasParams}}
128+
{{/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}}
129129
{{/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}} {
130130
{{^isDelegate}}
131131
return {{>returnValue}}

modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4299,6 +4299,111 @@ public void generateSortValidationDoesNotGenerateValidSortFileWhenBeanValidation
42994299
assertFileNotContains(petApi.toPath(), "@ValidSort");
43004300
}
43014301

4302+
// ========== PAGEABLE DEFAULTS TESTS ==========
4303+
4304+
@Test
4305+
public void pageableDefaultsGeneratesSortDefaultsForSingleDescField() throws Exception {
4306+
Map<String, Object> additionalProperties = new HashMap<>();
4307+
additionalProperties.put(USE_TAGS, "true");
4308+
additionalProperties.put(INTERFACE_ONLY, "true");
4309+
additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true");
4310+
4311+
Map<String, File> files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties);
4312+
4313+
File petApi = files.get("PetApi.kt");
4314+
assertFileContains(petApi.toPath(),
4315+
"@SortDefault.SortDefaults(SortDefault(sort = [\"name\"], direction = Sort.Direction.DESC))");
4316+
assertFileContains(petApi.toPath(), "import org.springframework.data.domain.Sort");
4317+
assertFileContains(petApi.toPath(), "import org.springframework.data.web.SortDefault");
4318+
}
4319+
4320+
@Test
4321+
public void pageableDefaultsGeneratesSortDefaultsForSingleAscField() throws Exception {
4322+
Map<String, Object> additionalProperties = new HashMap<>();
4323+
additionalProperties.put(USE_TAGS, "true");
4324+
additionalProperties.put(INTERFACE_ONLY, "true");
4325+
additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true");
4326+
4327+
Map<String, File> files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties);
4328+
4329+
File petApi = files.get("PetApi.kt");
4330+
assertFileContains(petApi.toPath(),
4331+
"@SortDefault.SortDefaults(SortDefault(sort = [\"id\"], direction = Sort.Direction.ASC))");
4332+
}
4333+
4334+
@Test
4335+
public void pageableDefaultsGeneratesSortDefaultsForMixedDirections() throws Exception {
4336+
Map<String, Object> additionalProperties = new HashMap<>();
4337+
additionalProperties.put(USE_TAGS, "true");
4338+
additionalProperties.put(INTERFACE_ONLY, "true");
4339+
additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true");
4340+
4341+
Map<String, File> files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties);
4342+
4343+
File petApi = files.get("PetApi.kt");
4344+
assertFileContains(petApi.toPath(),
4345+
"@SortDefault.SortDefaults(SortDefault(sort = [\"name\"], direction = Sort.Direction.DESC), SortDefault(sort = [\"id\"], direction = Sort.Direction.ASC))");
4346+
}
4347+
4348+
@Test
4349+
public void pageableDefaultsGeneratesPageableDefaultForPageAndSize() throws Exception {
4350+
Map<String, Object> additionalProperties = new HashMap<>();
4351+
additionalProperties.put(USE_TAGS, "true");
4352+
additionalProperties.put(INTERFACE_ONLY, "true");
4353+
additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true");
4354+
4355+
Map<String, File> files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties);
4356+
4357+
File petApi = files.get("PetApi.kt");
4358+
assertFileContains(petApi.toPath(), "@PageableDefault(page = 0, size = 25)");
4359+
assertFileContains(petApi.toPath(), "import org.springframework.data.web.PageableDefault");
4360+
}
4361+
4362+
@Test
4363+
public void pageableDefaultsGeneratesBothAnnotationsWhenAllDefaultsPresent() throws Exception {
4364+
Map<String, Object> additionalProperties = new HashMap<>();
4365+
additionalProperties.put(USE_TAGS, "true");
4366+
additionalProperties.put(INTERFACE_ONLY, "true");
4367+
additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true");
4368+
4369+
Map<String, File> files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties);
4370+
4371+
File petApi = files.get("PetApi.kt");
4372+
String content = Files.readString(petApi.toPath());
4373+
4374+
int methodStart = content.indexOf("fun findPetsWithAllDefaults(");
4375+
Assert.assertTrue(methodStart >= 0, "findPetsWithAllDefaults method should exist");
4376+
String methodBlock = content.substring(Math.max(0, methodStart - 500), methodStart + 500);
4377+
4378+
Assert.assertTrue(methodBlock.contains("@PageableDefault(page = 0, size = 10)"),
4379+
"findPetsWithAllDefaults should have @PageableDefault(page = 0, size = 10)");
4380+
Assert.assertTrue(methodBlock.contains(
4381+
"@SortDefault.SortDefaults(SortDefault(sort = [\"name\"], direction = Sort.Direction.DESC), SortDefault(sort = [\"id\"], direction = Sort.Direction.ASC))"),
4382+
"findPetsWithAllDefaults should have @SortDefault.SortDefaults with both fields");
4383+
}
4384+
4385+
@Test
4386+
public void pageableDefaultsDoesNotAnnotateNonPageableOperation() throws Exception {
4387+
Map<String, Object> additionalProperties = new HashMap<>();
4388+
additionalProperties.put(USE_TAGS, "true");
4389+
additionalProperties.put(INTERFACE_ONLY, "true");
4390+
additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true");
4391+
4392+
Map<String, File> files = generateFromContract("src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties);
4393+
4394+
File petApi = files.get("PetApi.kt");
4395+
String content = Files.readString(petApi.toPath());
4396+
4397+
// findPetsNonPaginatedWithSortEnum has no x-spring-paginated, so no pageable annotations
4398+
int methodStart = content.indexOf("fun findPetsNonPaginatedWithSortEnum(");
4399+
Assert.assertTrue(methodStart >= 0, "findPetsNonPaginatedWithSortEnum method should exist");
4400+
String methodBlock = content.substring(Math.max(0, methodStart - 500), methodStart);
4401+
Assert.assertFalse(methodBlock.contains("@SortDefault"),
4402+
"Non-paginated operation should not have @SortDefault");
4403+
Assert.assertFalse(methodBlock.contains("@PageableDefault"),
4404+
"Non-paginated operation should not have @PageableDefault");
4405+
}
4406+
43024407
@Test
43034408
public void autoXSpringPaginatedDetectsAllThreeParams() throws Exception {
43044409
Map<String, Object> additionalProperties = new HashMap<>();

0 commit comments

Comments
 (0)