Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/samples-kotlin-server-jdk17.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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/**

Expand Down Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions bin/configs/kotlin-spring-boot-sort-validation.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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"
useTags: "true"
requestMappingMode: api_interface
1 change: 1 addition & 0 deletions docs/generators/kotlin-spring.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|documentationProvider|Select the OpenAPI documentation provider.|<dl><dt>**none**</dt><dd>Do not publish an OpenAPI specification.</dd><dt>**source**</dt><dd>Publish the original input OpenAPI specification.</dd><dt>**springdoc**</dt><dd>Generate an OpenAPI 3 specification using SpringDoc.</dd></dl>|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|
|generateSortValidation|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.| |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|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;
Expand Down Expand Up @@ -98,6 +101,7 @@ 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 USE_SEALED_RESPONSE_INTERFACES = "useSealedResponseInterfaces";
public static final String COMPANION_OBJECT = "companionObject";

Expand Down Expand Up @@ -164,6 +168,7 @@ 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 useSealedResponseInterfaces = false;
@Setter private boolean companionObject = false;

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

// Map from operationId to allowed sort values for @ValidSort annotation generation
private Map<String, List<String>> sortValidationEnums = new HashMap<>();

public KotlinSpringServerCodegen() {
super();

Expand Down Expand Up @@ -272,6 +280,7 @@ 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 paginated operations whose 'sort' parameter has enum values. Requires useBeanValidation=true and library=spring-boot.", generateSortValidation);
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,
Expand Down Expand Up @@ -704,6 +713,10 @@ 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 (isUseSpringBoot3() && isUseSpringBoot4()) {
throw new IllegalArgumentException("Choose between Spring Boot 3 and Spring Boot 4");
}
Expand Down Expand Up @@ -1042,6 +1055,22 @@ 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
// Before removal, capture sort enum values for @ValidSort if generateSortValidation is enabled
if (generateSortValidation && useBeanValidation && sortValidationEnums.containsKey(codegenOperation.operationId)) {
List<String> allowedSortValues = sortValidationEnums.get(codegenOperation.operationId);
String allowedValuesStr = allowedSortValues.stream()
.map(v -> "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\"")
.collect(Collectors.joining(", "));
String validSortAnnotation = "@ValidSort(allowedValues = [" + allowedValuesStr + "])";

Object existingAnnotation = codegenOperation.vendorExtensions.get("x-operation-extra-annotation");
List<String> existingAnnotations = DefaultCodegen.getObjectAsStringList(existingAnnotation);
List<String> updatedAnnotations = new ArrayList<>(existingAnnotations);
updatedAnnotations.add(validSortAnnotation);
codegenOperation.vendorExtensions.put("x-operation-extra-annotation", updatedAnnotations);

codegenOperation.imports.add("ValidSort");
}
codegenOperation.queryParams.removeIf(param -> defaultPageableQueryParams.contains(param.baseName));
codegenOperation.allParams.removeIf(param -> param.isQueryParam && defaultPageableQueryParams.contains(param.baseName));
}
Expand All @@ -1058,6 +1087,10 @@ public void preprocessOpenAPI(OpenAPI openAPI) {
(sourceFolder + File.separator + configPackage).replace(".", java.io.File.separator), "EnumConverterConfiguration.kt"));
}

if (SPRING_BOOT.equals(library) && generateSortValidation && useBeanValidation) {
scanSortValidationEnums(openAPI);
}

if (!additionalProperties.containsKey(TITLE)) {
// The purpose of the title is for:
// - README documentation
Expand Down Expand Up @@ -1123,6 +1156,78 @@ public void preprocessOpenAPI(OpenAPI openAPI) {
// TODO: Handle tags
}

/**
* Scans the OpenAPI spec for paginated operations whose 'sort' parameter has enum values,
* builds the {@link #sortValidationEnums} registry, and registers the ValidSort.kt supporting file.
* Called from {@link #preprocessOpenAPI} when {@code generateSortValidation} is enabled.
*/
private void scanSortValidationEnums(OpenAPI openAPI) {
if (openAPI.getPaths() == null) {
return;
}
boolean foundAny = false;
for (Map.Entry<String, PathItem> pathEntry : openAPI.getPaths().entrySet()) {
for (Operation operation : pathEntry.getValue().readOperations()) {
String operationId = operation.getOperationId();
if (operationId == null || !willBePageable(operation)) {
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<String> enumValues = schema.getEnum().stream()
.map(Object::toString)
.collect(Collectors.toList());
sortValidationEnums.put(operationId, enumValues);
foundAny = true;
}
}
}
if (foundAny) {
importMapping.putIfAbsent("ValidSort", configPackage + ".ValidSort");
supportingFiles.add(new SupportingFile("validSort.mustache",
(sourceFolder + File.separator + configPackage).replace(".", File.separator), "ValidSort.kt"));
}
}

/**
* Returns true if the given operation will have a Pageable parameter injected — either because
* it has {@code x-spring-paginated: true} explicitly, or because {@link #autoXSpringPaginated}
* is enabled and the operation has all three default pagination query parameters (page, size, sort).
*/
private boolean willBePageable(Operation operation) {
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<String> paramNames = operation.getParameters().stream()
.map(Parameter::getName)
.collect(Collectors.toSet());
return paramNames.containsAll(Arrays.asList("page", "size", "sort"));
}
return false;
}

@Override
public void postProcessModelProperty(CodegenModel model, CodegenProperty property) {
super.postProcessModelProperty(model, property);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package {{configPackage}}

import {{javaxPackage}}.validation.Constraint
import {{javaxPackage}}.validation.ConstraintValidator
import {{javaxPackage}}.validation.ConstraintValidatorContext
import {{javaxPackage}}.validation.Payload
import {{javaxPackage}}.validation.constraintvalidation.SupportedValidationTarget
import {{javaxPackage}}.validation.constraintvalidation.ValidationTarget
import org.springframework.data.domain.Pageable

/**
* Validates that sort properties in a [Pageable] parameter match the allowed values.
*
* This annotation can only be applied to methods that have a [Pageable] parameter.
* The validator checks that each sort property and direction combination in the [Pageable]
* matches one of the strings specified in [allowedValues].
*
* Expected value format: `"property,direction"` (e.g. `"id,asc"`, `"name,desc"`).
*
* @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.FUNCTION)
annotation class ValidSort(
val allowedValues: Array<String>,
val groups: Array<kotlin.reflect.KClass<*>> = [],
val payload: Array<kotlin.reflect.KClass<out Payload>> = [],
val message: String = "Invalid sort column"
)

@SupportedValidationTarget(ValidationTarget.PARAMETERS)
class SortValidator : ConstraintValidator<ValidSort, Array<Any?>> {

private lateinit var allowedValues: Set<String>

override fun initialize(constraintAnnotation: ValidSort) {
allowedValues = constraintAnnotation.allowedValues.toSet()
}

override fun isValid(parameters: Array<Any?>?, context: ConstraintValidatorContext): Boolean {
val pageable = parameters?.filterIsInstance<Pageable>()?.firstOrNull()
?: throw IllegalStateException(
"@ValidSort can only be used on methods with a Pageable parameter. " +
"Ensure the annotated method has a parameter of type org.springframework.data.domain.Pageable."
)

val invalid = pageable.sort
.foldIndexed(emptyMap<Int, String>()) { index, acc, order ->
val sortValue = "${order.property},${order.direction.name.lowercase()}"
if (sortValue !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()
}
}
Loading
Loading