diff --git a/build-logic/src/main/groovy/io.micronaut.build.internal.openapi-generator-test-suite.gradle b/build-logic/src/main/groovy/io.micronaut.build.internal.openapi-generator-test-suite.gradle index b636cbe8ad..45768bc435 100644 --- a/build-logic/src/main/groovy/io.micronaut.build.internal.openapi-generator-test-suite.gradle +++ b/build-logic/src/main/groovy/io.micronaut.build.internal.openapi-generator-test-suite.gradle @@ -25,6 +25,8 @@ def openapiGenerate = tasks.register("generateOpenApi", OpenApiGeneratorTask) { outputDirectory.convention(layout.buildDirectory.dir("generated/openapi")) generatorKind.convention("client") outputKinds.convention(["models", "apis", "supportingFiles", "modelTests", "apiTests"]) + parameterMappings.convention([]) + responseBodyMappings.convention([]) } sourceSets { diff --git a/build-logic/src/main/groovy/io/micronaut/build/internal/openapi/OpenApiGeneratorTask.java b/build-logic/src/main/groovy/io/micronaut/build/internal/openapi/OpenApiGeneratorTask.java index c0cda05602..dafe05a59a 100644 --- a/build-logic/src/main/groovy/io/micronaut/build/internal/openapi/OpenApiGeneratorTask.java +++ b/build-logic/src/main/groovy/io/micronaut/build/internal/openapi/OpenApiGeneratorTask.java @@ -38,6 +38,7 @@ import java.nio.file.Files; import java.util.ArrayList; import java.util.List; +import java.util.Map; /** * A task which simulates what the Gradle Micronaut plugin @@ -70,6 +71,12 @@ public Provider getGeneratedTestSourcesDirectory() { @Input public abstract ListProperty getOutputKinds(); + @Input + public abstract ListProperty> getParameterMappings(); + + @Input + public abstract ListProperty> getResponseBodyMappings(); + @Inject protected abstract ExecOperations getExecOperations(); @@ -80,6 +87,7 @@ public void execute() throws IOException { var generatedTestSourcesDir = getGeneratedTestSourcesDirectory().get().getAsFile(); Files.createDirectories(generatedSourcesDir.toPath()); Files.createDirectories(generatedTestSourcesDir.toPath()); + getProject().getLogger().info("json: " + getParameterMappings().get()); getExecOperations().javaexec(javaexec -> { javaexec.setClasspath(getClasspath()); javaexec.getMainClass().set("io.micronaut.openapi.testsuite.GeneratorMain"); @@ -88,6 +96,8 @@ public void execute() throws IOException { args.add(getOpenApiDefinition().get().getAsFile().toURI().toString()); args.add(getOutputDirectory().get().getAsFile().getAbsolutePath()); args.add(String.join(",", getOutputKinds().get())); + args.add(getParameterMappings().get().toString()); + args.add(getResponseBodyMappings().get().toString()); javaexec.args(args); }); } diff --git a/openapi-generator/src/main/java/io/micronaut/openapi/generator/AbstractMicronautJavaCodegen.java b/openapi-generator/src/main/java/io/micronaut/openapi/generator/AbstractMicronautJavaCodegen.java index f379b00976..8d5b4babb5 100644 --- a/openapi-generator/src/main/java/io/micronaut/openapi/generator/AbstractMicronautJavaCodegen.java +++ b/openapi-generator/src/main/java/io/micronaut/openapi/generator/AbstractMicronautJavaCodegen.java @@ -20,6 +20,7 @@ import com.samskivert.mustache.Template; import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.servers.Server; import org.apache.commons.lang3.StringUtils; import org.openapitools.codegen.CliOption; import org.openapitools.codegen.CodegenConstants; @@ -41,12 +42,17 @@ import java.io.File; import java.io.IOException; import java.io.Writer; +import java.util.ArrayList; import java.util.Arrays; import java.util.EnumSet; import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; import static org.openapitools.codegen.CodegenConstants.INVOKER_PACKAGE; @@ -94,6 +100,8 @@ public abstract class AbstractMicronautJavaCodegen parameterMappings = new ArrayList<>(); + protected List responseBodyMappings = new ArrayList<>(); protected AbstractMicronautJavaCodegen() { super(); @@ -372,6 +380,14 @@ public void processOpts() { additionalProperties.put("indent", new Formatting.IndentFormatter(4)); } + public void addParameterMappings(List parameterMappings) { + this.parameterMappings.addAll(parameterMappings); + } + + public void addResponseBodyMappings(List responseBodyMappings) { + this.responseBodyMappings.addAll(responseBodyMappings); + } + // CHECKSTYLE:OFF private void maybeSetSwagger() { if (additionalProperties.containsKey(OPT_GENERATE_SWAGGER_ANNOTATIONS)) { @@ -571,6 +587,130 @@ public CodegenModel fromModel(String name, Schema model) { return codegenModel; } + + @Override + public CodegenOperation fromOperation(String path, String httpMethod, Operation operation, + List servers) { + CodegenOperation op = super.fromOperation(path, httpMethod, operation, servers); + + op.vendorExtensions.put("originalParams", new ArrayList(op.allParams)); + op.vendorExtensions.put("originReturnProperty", op.returnProperty); + processParametersWithAdditionalMappings(op.allParams, op.imports); + processWithResponseBodyMapping(op); + + return op; + } + + /** + * Method that maps parameters if a corresponding mapping is specified. + * + * @param params The parameters to modify. + * @param imports The operation imports. + */ + private void processParametersWithAdditionalMappings(List params, Set imports) { + Map additionalMappings = new LinkedHashMap<>(); + Iterator iter = params.iterator(); + while (iter.hasNext()) { + CodegenParameter param = iter.next(); + boolean paramWasMapped = false; + for (ParameterMapping mapping : parameterMappings) { + if (mapping.doesMatch(param)) { + additionalMappings.put(mapping.mappedName(), mapping); + paramWasMapped = true; + } + } + if (paramWasMapped) { + iter.remove(); + } + } + + for (ParameterMapping mapping : additionalMappings.values()) { + if (mapping.mappedType() != null) { + CodegenParameter newParam = new CodegenParameter(); + newParam.paramName = mapping.mappedName(); + newParam.required = true; + newParam.isModel = mapping.isValidated; + + String typeName = makeSureImported(mapping.mappedType(), imports); + newParam.dataType = typeName; + + // Set the paramName if required + if (newParam.paramName == null) { + newParam.paramName = toParamName(typeName); + } + + params.add(newParam); + } + } + } + + /** + * Method that changes the return type if the corresponding header is specified. + * + * @param op The operation to modify. + */ + private void processWithResponseBodyMapping(CodegenOperation op) { + ResponseBodyMapping bodyMapping = null; + + Iterator iter = op.responseHeaders.iterator(); + while (iter.hasNext()) { + CodegenProperty header = iter.next(); + boolean headerWasMapped = false; + for (ResponseBodyMapping mapping : responseBodyMappings) { + if (mapping.doesMatch(header.baseName, op.isArray)) { + if (mapping.mappedBodyType() != null) { + bodyMapping = mapping; + } + headerWasMapped = true; + } + } + if (headerWasMapped) { + iter.remove(); + } + } + + if (bodyMapping != null) { + CodegenProperty newProperty = new CodegenProperty(); + newProperty.required = true; + newProperty.isModel = bodyMapping.isValidated; + + String typeName = makeSureImported(bodyMapping.mappedBodyType(), op.imports); + + if (bodyMapping.isListWrapper) { + newProperty.dataType = typeName + '<' + op.returnBaseType + '>'; + newProperty.items = op.returnProperty.items; + } else { + newProperty.dataType = typeName + '<' + op.returnType + '>'; + newProperty.items = op.returnProperty; + } + + op.returnType = newProperty.dataType; + op.returnContainer = null; + op.returnProperty = newProperty; + } + } + + private String makeSureImported(String typeName, Set imports) { + // Find the index of the first capital letter + int firstCapitalIndex = 0; + for (int i = 0; i < typeName.length(); i++) { + if (Character.isUpperCase(typeName.charAt(i))) { + firstCapitalIndex = i; + break; + } + } + + // Add import if the name is fully-qualified + if (firstCapitalIndex != 0) { + // Add import if fully-qualified name is used + String dataType = typeName.substring(firstCapitalIndex); + importMapping.put(dataType, typeName); + typeName = dataType; + } + imports.add(typeName); + return typeName; + } + @Override public Map postProcessAllModels(Map objs) { objs = super.postProcessAllModels(objs); @@ -746,6 +886,78 @@ private static class ReplaceDotsWithUnderscoreLambda implements Mustache.Lambda public void execute(final Template.Fragment fragment, final Writer writer) throws IOException { writer.write(fragment.execute().replace('.', '_')); } + } + + /** + * A record that can be used to specify parameter mapping. + * Parameter mapping would map a given parameter to a specific type and name. + * + * @param name The name of the parameter as described by the name field in specification. + * @param location The location of parameter. Path parameters cannot be mapped, as this + * behavior should not be used. + * @param mappedType The type to which the parameter should be mapped. If multiple parameters + * have the same mapping, only one parameter will be present. If set to null, + * the original parameter will be deleted. + * @param mappedName The unique name of the parameter to be used as method parameter name. + * @param isValidated Whether the mapped parameter requires validation. + */ + public record ParameterMapping ( + String name, + ParameterLocation location, + String mappedType, + String mappedName, + boolean isValidated + ) { + private boolean doesMatch(CodegenParameter parameter) { + if (name != null && !name.equals(parameter.baseName)) { + return false; + } + if (location == null) { + return true; + } + return switch (location) { + case HEADER -> parameter.isHeaderParam; + case QUERY -> parameter.isQueryParam; + case FORM -> parameter.isFormParam; + case COOKIE -> parameter.isCookieParam; + case BODY -> parameter.isBodyParam; + }; + } + /** + * The location of the parameter to be mapped. + */ + public enum ParameterLocation { + HEADER, + QUERY, + FORM, + COOKIE, + BODY + } + } + + /** + * A record that can be used to specify parameter mapping. + * Parameter mapping would map a given parameter to a specific type and name. + * + * @param headerName The response header name that triggers the change of response type. + * @param mappedBodyType The type in which will be used as the response type. The type must take + * a single type parameter, which will be the original body. + * @param isListWrapper Whether the mapped body type needs to be supplied list items + * as property. + * @param isValidated Whether the mapped response body type required validation. + */ + public record ResponseBodyMapping ( + String headerName, + String mappedBodyType, + boolean isListWrapper, + boolean isValidated + ) { + private boolean doesMatch(String header, boolean isBodyList) { + if (isListWrapper && !isBodyList) { + return false; + } + return Objects.equals(headerName, header); + } } } diff --git a/openapi-generator/src/main/java/io/micronaut/openapi/generator/MicronautCodeGeneratorEntryPoint.java b/openapi-generator/src/main/java/io/micronaut/openapi/generator/MicronautCodeGeneratorEntryPoint.java index d0fabfec48..3a4cdf6815 100644 --- a/openapi-generator/src/main/java/io/micronaut/openapi/generator/MicronautCodeGeneratorEntryPoint.java +++ b/openapi-generator/src/main/java/io/micronaut/openapi/generator/MicronautCodeGeneratorEntryPoint.java @@ -26,6 +26,7 @@ import java.net.URI; import java.util.Arrays; import java.util.EnumSet; +import java.util.List; import java.util.Objects; import java.util.function.Consumer; @@ -118,6 +119,12 @@ private void configureOptions() { if (options.artifactId != null) { codeGenerator.setArtifactId(options.artifactId); } + if (options.parameterMappings != null) { + codeGenerator.addParameterMappings(options.parameterMappings); + } + if (options.responseBodyMappings != null) { + codeGenerator.addResponseBodyMappings(options.responseBodyMappings); + } codeGenerator.setReactive(options.reactive); codeGenerator.setWrapInHttpResponse(options.wrapInHttpResponse); codeGenerator.setUseOptional(options.optional); @@ -288,6 +295,8 @@ private static class DefaultOptionsBuilder implements MicronautCodeGeneratorOpti private boolean beanValidation = true; private String invokerPackage; private String modelPackage; + private List parameterMappings; + private List responseBodyMappings; private boolean optional = false; private boolean reactive = true; private boolean wrapInHttpResponse; @@ -318,6 +327,18 @@ public MicronautCodeGeneratorOptionsBuilder withArtifactId(String artifactId) { return this; } + @Override + public MicronautCodeGeneratorOptionsBuilder withParameterMappings(List parameterMappings) { + this.parameterMappings = parameterMappings; + return this; + } + + @Override + public MicronautCodeGeneratorOptionsBuilder withResponseBodyMappings(List responseBodyMappings) { + this.responseBodyMappings = responseBodyMappings; + return this; + } + @Override public MicronautCodeGeneratorOptionsBuilder withReactive(boolean reactive) { this.reactive = reactive; @@ -355,7 +376,7 @@ public MicronautCodeGeneratorOptionsBuilder withSerializationLibrary(Serializati } private Options build() { - return new Options(apiPackage, modelPackage, invokerPackage, artifactId, beanValidation, optional, reactive, wrapInHttpResponse, testFramework, serializationLibraryKind); + return new Options(apiPackage, modelPackage, invokerPackage, artifactId, parameterMappings, responseBodyMappings, beanValidation, optional, reactive, wrapInHttpResponse, testFramework, serializationLibraryKind); } } } @@ -380,6 +401,8 @@ private record Options( String modelPackage, String invokerPackage, String artifactId, + List parameterMappings, + List responseBodyMappings, boolean beanValidation, boolean optional, boolean reactive, diff --git a/openapi-generator/src/main/java/io/micronaut/openapi/generator/MicronautCodeGeneratorOptionsBuilder.java b/openapi-generator/src/main/java/io/micronaut/openapi/generator/MicronautCodeGeneratorOptionsBuilder.java index 40566c8d85..0b0a0d942c 100644 --- a/openapi-generator/src/main/java/io/micronaut/openapi/generator/MicronautCodeGeneratorOptionsBuilder.java +++ b/openapi-generator/src/main/java/io/micronaut/openapi/generator/MicronautCodeGeneratorOptionsBuilder.java @@ -15,6 +15,8 @@ */ package io.micronaut.openapi.generator; +import java.util.List; + /** * Builder for generic options that the Micronaut code generator supports. */ @@ -53,6 +55,23 @@ public interface MicronautCodeGeneratorOptionsBuilder { */ MicronautCodeGeneratorOptionsBuilder withArtifactId(String artifactId); + /** + * Add the parameter mappings. + * + * @param parameterMappings the parameter mappings specified by a {@link AbstractMicronautJavaCodegen.ParameterMapping} objects + * @return this builder + */ + MicronautCodeGeneratorOptionsBuilder withParameterMappings(List parameterMappings); + + /** + * Add the response body mappings. + * + * @param responseBodyMappings the response body mappings specified by a {@link AbstractMicronautJavaCodegen.ResponseBodyMapping} objects + * @return this builder + */ + MicronautCodeGeneratorOptionsBuilder withResponseBodyMappings(List responseBodyMappings); + + /** * If set to true, the generator will use reactive types. * diff --git a/openapi-generator/src/main/resources/templates/java-micronaut/common/operationAnnotations.mustache b/openapi-generator/src/main/resources/templates/java-micronaut/common/operationAnnotations.mustache index 36a3b9b393..06ef88c69b 100644 --- a/openapi-generator/src/main/resources/templates/java-micronaut/common/operationAnnotations.mustache +++ b/openapi-generator/src/main/resources/templates/java-micronaut/common/operationAnnotations.mustache @@ -75,9 +75,9 @@ {{/responses}} }{{#hasParams}}, parameters = { - {{#allParams}} + {{#vendorExtensions.originalParams}} @Parameter(name = "{{paramName}}"{{#description}}, description = "{{{description}}}"{{/description}}{{#required}}, required = true{{/required}}){{^-last}},{{/-last}} - {{/allParams}} + {{/vendorExtensions.originalParams}} }{{/hasParams}}{{#hasAuthMethods}}, security = { {{#authMethods}} @@ -101,4 +101,4 @@ {{/implicitHeadersParams}} }) {{/generateSwagger1Annotations}} - {{/implicitHeadersParams.0}} \ No newline at end of file + {{/implicitHeadersParams.0}} diff --git a/test-suite-generator-util/src/main/java/io/micronaut/openapi/testsuite/GeneratorMain.java b/test-suite-generator-util/src/main/java/io/micronaut/openapi/testsuite/GeneratorMain.java index 67d936749b..57ff70941b 100644 --- a/test-suite-generator-util/src/main/java/io/micronaut/openapi/testsuite/GeneratorMain.java +++ b/test-suite-generator-util/src/main/java/io/micronaut/openapi/testsuite/GeneratorMain.java @@ -15,12 +15,18 @@ */ package io.micronaut.openapi.testsuite; +import io.micronaut.openapi.generator.AbstractMicronautJavaCodegen; import io.micronaut.openapi.generator.MicronautCodeGeneratorEntryPoint; import java.io.File; import java.net.URI; import java.net.URISyntaxException; +import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; /** * An entry point to be used in tests, to simulate @@ -42,6 +48,11 @@ public class GeneratorMain { */ public static void main(String[] args) throws URISyntaxException { boolean server = "server".equals(args[0]); + List parameterMappings = + parseParameterMappings(args[4]); + List responseBodyMappings = + parseResponseBodyMappings(args[5]); + MicronautCodeGeneratorEntryPoint.OutputKind[] outputKinds = Arrays.stream(args[3].split(",")) .map(MicronautCodeGeneratorEntryPoint.OutputKind::of) @@ -59,6 +70,8 @@ public static void main(String[] args) throws URISyntaxException { options.withOptional(true); options.withReactive(true); options.withTestFramework(MicronautCodeGeneratorEntryPoint.TestFramework.SPOCK); + options.withParameterMappings(parameterMappings); + options.withResponseBodyMappings(responseBodyMappings); }); if (server) { builder.forServer(serverOptions -> { @@ -74,4 +87,71 @@ public static void main(String[] args) throws URISyntaxException { } builder.build().generate(); } + + private static List parseParameterMappings(String string) { + return parseListOfMaps(string).stream().map(map -> new AbstractMicronautJavaCodegen.ParameterMapping( + map.get("name"), + AbstractMicronautJavaCodegen.ParameterMapping.ParameterLocation.valueOf(map.get("location")), + map.get("mappedType"), + map.get("mappedName"), + "true".equals(map.get("isValidated")) + )).collect(Collectors.toList()); + } + + private static List parseResponseBodyMappings(String string) { + return parseListOfMaps(string).stream().map(map -> new AbstractMicronautJavaCodegen.ResponseBodyMapping( + map.get("headerName"), + map.get("mappedBodyType"), + "true".equals(map.get("isListWrapper")), + "true".equals(map.get("isValidated")) + )).collect(Collectors.toList()); + } + + private static List> parseListOfMaps(String string) { + List> result = new ArrayList<>(); + if (string.isBlank()) { + return result; + } + + assert string.charAt(0) == '['; + int i = 1; + + while(string.charAt(i) != ']') { + if (string.charAt(i) == ' ') { + ++i; + } + + assert string.charAt(i) == '{'; + ++i; + + Map map = new HashMap<>(); + result.add(map); + int endIndex = string.indexOf('}', i); + + while (i < endIndex) { + if (string.charAt(i) == ' ') { + ++i; + } + int nameIndex = string.indexOf('=', i); + String name = string.substring(i, nameIndex); + i = nameIndex + 1; + int valueIndex = string.indexOf(',', i); + if (endIndex < valueIndex || valueIndex == -1) { + valueIndex = endIndex; + } + String value = string.substring(i, valueIndex); + i = valueIndex + 1; + + map.put(name, value); + } + + if (i != string.length() - 1) { + assert string.charAt(i) == ','; + ++i; + } + } + assert i == string.length() - 1; + + return result; + } } diff --git a/test-suite-server-generator/build.gradle b/test-suite-server-generator/build.gradle index 335f709bf7..452f1d64a7 100644 --- a/test-suite-server-generator/build.gradle +++ b/test-suite-server-generator/build.gradle @@ -23,7 +23,7 @@ dependencies { testCompileOnly("io.micronaut:micronaut-inject-java-test") testImplementation("io.micronaut.test:micronaut-test-spock") testImplementation("io.micronaut:micronaut-http-client") - + implementation(mnData.micronaut.data.runtime) testRuntimeOnly("io.micronaut:micronaut-json-core") testRuntimeOnly("io.micronaut.serde:micronaut-serde-jackson") @@ -40,4 +40,20 @@ tasks.named("generateOpenApi") { generatorKind = "server" openApiDefinition = layout.projectDirectory.file("spec.yaml") outputKinds = ["models", "apis", "supportingFiles"] + parameterMappings = [ + // Pageable parameter + [name: "page", location: "QUERY", mappedType: "io.micronaut.data.model.Pageable"], + [name: "size", location: "QUERY", mappedType: "io.micronaut.data.model.Pageable"], + [name: "sortOrder", location: "QUERY", mappedType: "io.micronaut.data.model.Pageable"], + // Ignored header + [name: "ignored-header", location: "HEADER"], + // Custom filtering header + [name: "Filter", location: "HEADER", mappedType: "io.micronaut.openapi.test.filter.MyFilter"] + ] + responseBodyMappings = [ + // Response with Last-Modified header mapping + [headerName: "Last-Modified", mappedBodyType: "io.micronaut.openapi.test.dated.DatedResponse"], + // Response with Page body + [headerName: "X-Page-Number", mappedBodyType: "io.micronaut.data.model.Page", isListWrapper: true] + ] } diff --git a/test-suite-server-generator/spec.yaml b/test-suite-server-generator/spec.yaml index 85deaafed2..38501c5cf4 100644 --- a/test-suite-server-generator/spec.yaml +++ b/test-suite-server-generator/spec.yaml @@ -13,32 +13,28 @@ produces: - application/json x-headers: - PageNumberHeader: &Page-Number-header - name: Page-Number - in: header + PageNumberHeader: &X-Page-Number-header + name: X-Page-Number type: string description: The page number of the current page - PageSizeHeader: &Page-Size-header - name: Page-Size - in: header + PageSizeHeader: &X-Page-Size-header + name: X-Page-Size type: string description: The number of items per page - TotalCountHeader: &Total-Count-header - name: Total-Count - in: header + TotalCountHeader: &X-Total-Count-header + name: X-Total-Count type: string description: | The total number of items available in the entire collections, not just the items returned in the current page - PageCountHeader: &Page-Count-header - name: Page-Count - in: header + PageCountHeader: &X-Page-Count-header + name: X-Page-Count type: string description: The total number of pages based on the page size and total count - LinkHeader: &Link-header - name: Link - in: header + LastModifiedHeader: &Last-Modified-header + name: Last-Modified type: string - description: The URLS to the first, last, previous and next pages. + format: date-time + description: The last time an entity returned with the response was modified. paths: /sendPrimitives/{name}: @@ -194,6 +190,18 @@ paths: description: Success schema: type: string + /sendMappedParameter: + get: + operationId: sendMappedParameter + tags: [ parameters ] + description: A method that has a header that is mapped to a custom type + parameters: + - $ref: '#/parameters/FilterHeader' + responses: + 200: + description: Success + schema: + type: string /sendValidatedCollection: post: operationId: sendValidatedCollection @@ -486,11 +494,10 @@ paths: 200: description: Success headers: - Page-Number: *Page-Number-header - Page-Size: *Page-Size-header - Total-Count: *Total-Count-header - Page-Count: *Page-Count-header - Link: *Link-header + X-Page-Number: *X-Page-Number-header + X-Page-Size: *X-Page-Size-header + X-Total-Count: *X-Total-Count-header + X-Page-Count: *X-Page-Count-header schema: type: array items: @@ -501,13 +508,10 @@ paths: tags: [ responseBody ] description: A method to get a simple model with last-modified header responses: - 202: + 200: description: Success headers: - Last-Modified: - type: string - format: date-time - description: The last modified date for the requested object + Last-Modified: *Last-Modified-header schema: $ref: '#/definitions/SimpleModel' /getSimpleModelWithNonMappedHeader: @@ -535,8 +539,7 @@ paths: 200: description: Success headers: - Last-Modified: - type: string + Last-Modified: *Last-Modified-header custom-header: type: string description: A custom header @@ -771,6 +774,13 @@ parameters: Parameter describing the sort. Allows specifying the sorting direction using the keywords {@code asc} and {@code desc} after each property. For example, {@code "sort=name desc,age"} will sort by name in descending order and age in ascending. + FilterHeader: + name: Filter + in: header + type: string + description: | + A filter parameter that allows filtering the response. The conditions are comma separated and + must be of type [property][comparator][value] where comparator is one of =, < and >. responses: Error: diff --git a/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/api/ParametersController.java b/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/api/ParametersController.java index 4b55f560c4..894780389f 100644 --- a/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/api/ParametersController.java +++ b/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/api/ParametersController.java @@ -1,5 +1,8 @@ package io.micronaut.openapi.test.api; +import io.micronaut.data.model.Pageable; +import io.micronaut.data.model.Sort; +import io.micronaut.openapi.test.filter.MyFilter; import io.micronaut.openapi.test.model.SendDatesResponse; import io.micronaut.openapi.test.model.SendPrimitivesResponse; import io.micronaut.http.annotation.Controller; @@ -8,6 +11,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.OffsetDateTime; +import java.util.stream.Collectors; @Controller public class ParametersController implements ParametersApi { @@ -45,12 +49,27 @@ public Mono getIgnoredHeader() { } @Override - public Mono sendIgnoredHeader(String header) { + public Mono sendIgnoredHeader() { return Mono.just("Success"); } @Override - public Mono sendPageQuery(Integer page, Integer size, String sort) { - return Mono.just("(page: " + page + ", size: " + size + ", sort: " + sort + ")"); + public Mono sendPageQuery(Pageable pageable) { + return Mono.just( + "(page: " + pageable.getNumber() + + ", size: " + pageable.getSize() + + ", sort: " + sortToString(pageable.getSort()) + ")" + ); + } + + @Override + public Mono sendMappedParameter(MyFilter myFilter) { + return Mono.just(myFilter.toString()); + } + + private String sortToString(Sort sort) { + return sort.getOrderBy().stream().map( + order -> order.getProperty() + "(dir=" + order.getDirection() + ")" + ).collect(Collectors.joining(" ")); } } diff --git a/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/api/ResponseBodyController.java b/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/api/ResponseBodyController.java index 7276e6d263..4c2e67435f 100644 --- a/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/api/ResponseBodyController.java +++ b/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/api/ResponseBodyController.java @@ -1,6 +1,9 @@ package io.micronaut.openapi.test.api; +import io.micronaut.data.model.Page; +import io.micronaut.data.model.Pageable; import io.micronaut.http.multipart.CompletedFileUpload; +import io.micronaut.openapi.test.dated.DatedResponse; import io.micronaut.openapi.test.model.SimpleModel; import io.micronaut.openapi.test.model.StateEnum; import io.micronaut.http.HttpStatus; @@ -9,6 +12,7 @@ import reactor.core.publisher.Mono; import java.io.ByteArrayInputStream; +import java.time.ZonedDateTime; import java.util.List; @Controller @@ -31,19 +35,24 @@ public class ResponseBodyController implements ResponseBodyApi { .state(StateEnum.RUNNING) .points(List.of("1,1", "2,2", "3,3"))); + public static final String LAST_MODIFIED_STRING = "2023-01-24T10:15:59.100+06:00"; + + public static final ZonedDateTime LAST_MODIFIED_DATE = + ZonedDateTime.parse(LAST_MODIFIED_STRING); + @Override public Mono getSimpleModel() { return Mono.just(SIMPLE_MODEL); } @Override - public Mono> getPaginatedSimpleModel(Integer page) { - return Mono.just(SIMPLE_MODELS); + public Mono> getPaginatedSimpleModel(Pageable pageable) { + return Mono.just(Page.of(SIMPLE_MODELS, pageable, SIMPLE_MODELS.size())); } @Override - public Mono getDatedSimpleModel() { - return Mono.just(SIMPLE_MODEL); + public Mono> getDatedSimpleModel() { + return Mono.just(DatedResponse.of(SIMPLE_MODEL).withLastModified(LAST_MODIFIED_DATE)); } @Override @@ -52,8 +61,8 @@ public Mono getSimpleModelWithNonStandardStatus() { } @Override - public Mono getDatedSimpleModelWithNonMappedHeader() { - return Mono.just(SIMPLE_MODEL); + public Mono> getDatedSimpleModelWithNonMappedHeader() { + return Mono.just(DatedResponse.of(SIMPLE_MODEL)); } @Override diff --git a/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/dated/DatedResponse.java b/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/dated/DatedResponse.java new file mode 100644 index 0000000000..efaaa72c22 --- /dev/null +++ b/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/dated/DatedResponse.java @@ -0,0 +1,73 @@ +package io.micronaut.openapi.test.dated; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; + +import java.time.ZonedDateTime; + +/** + * A response that contains information about last modification. + * + * @param The response body type. + */ +public final class DatedResponse { + + @Nullable + private ZonedDateTime lastModified; + + @NonNull + private final T body; + + private DatedResponse(T body, ZonedDateTime lastModified) { + this.body = body; + this.lastModified = lastModified; + } + + /** + * Set the last modified to this object. + * + * @param lastModified the last modification date. + * @return this response. + */ + public DatedResponse withLastModified(ZonedDateTime lastModified) { + this.lastModified = lastModified; + return this; + } + + /** + * @return The last modification date of returned resource. + */ + public ZonedDateTime getLastModified() { + return lastModified; + } + + /** + * @return The response body. + */ + public T getBody() { + return body; + } + + /** + * Create a response by specifying only the body. + * + * @param body The response body. + * @return The response. + * @param The response body type. + */ + public static DatedResponse of(@NonNull T body) { + return new DatedResponse(body, null); + } + + /** + * Create a response by specifying both the body and last modification date of the resource. + * + * @param body The body. + * @param lastModified The last modification date. + * @return The response. + * @param The body type. + */ + public static DatedResponse of(@NonNull T body, @Nullable ZonedDateTime lastModified) { + return new DatedResponse(body, lastModified); + } +} diff --git a/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/dated/DatedResponseBodyWriter.java b/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/dated/DatedResponseBodyWriter.java new file mode 100644 index 0000000000..e00d2b25b1 --- /dev/null +++ b/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/dated/DatedResponseBodyWriter.java @@ -0,0 +1,90 @@ +package io.micronaut.openapi.test.dated; + + +import io.micronaut.context.exceptions.ConfigurationException; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.annotation.Order; +import io.micronaut.core.type.Argument; +import io.micronaut.core.type.MutableHeaders; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.body.MessageBodyHandlerRegistry; +import io.micronaut.http.body.MessageBodyWriter; +import io.micronaut.http.codec.CodecException; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import java.io.OutputStream; +import java.util.List; + +/** + * An class for writing {@link DatedResponse} to the HTTP response with JSON body. + * + * @param the type of the response body + */ +@Singleton +@Produces(MediaType.APPLICATION_JSON) +@Order(-1) +final class DatedResponseBodyWriter implements MessageBodyWriter> { + + private static final String LAST_MODIFIED_HEADER = "Last-Modified"; + + private final MessageBodyHandlerRegistry registry; + private final MessageBodyWriter bodyWriter; + private final Argument bodyType; + + @Inject + DatedResponseBodyWriter(MessageBodyHandlerRegistry registry) { + this(registry, null, null); + } + + private DatedResponseBodyWriter( + MessageBodyHandlerRegistry registry, + @Nullable MessageBodyWriter bodyWriter, + @Nullable Argument bodyType + ) { + this.registry = registry; + this.bodyWriter = bodyWriter; + this.bodyType = bodyType; + } + + @Override + public MessageBodyWriter> createSpecific( + Argument> type + ) { + Argument bt = type.getTypeParameters()[0]; + MessageBodyWriter writer = registry.findWriter(bt, List.of(MediaType.APPLICATION_JSON_TYPE)) + .orElseThrow(() -> new ConfigurationException("No JSON message writer present")); + return new DatedResponseBodyWriter<>(registry, writer, bt); + } + + @Override + public void writeTo( + Argument> type, + MediaType mediaType, + DatedResponse dated, + MutableHeaders headers, + OutputStream outputStream + ) throws CodecException { + if (bodyType != null && bodyWriter != null) { + headers.add(LAST_MODIFIED_HEADER, dated.getLastModified().toString()); + bodyWriter.writeTo(bodyType, mediaType, dated.getBody(), headers, outputStream); + } else { + throw new ConfigurationException("No JSON message writer present"); + } + } + + @Override + public boolean isWriteable( + Argument> type, + MediaType mediaType + ) { + return bodyType != null && bodyWriter != null && bodyWriter.isWriteable(bodyType, mediaType); + } + + @Override + public boolean isBlocking() { + return bodyWriter != null && bodyWriter.isBlocking(); + } + +} diff --git a/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/filter/MyFilter.java b/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/filter/MyFilter.java new file mode 100644 index 0000000000..71743f00c5 --- /dev/null +++ b/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/filter/MyFilter.java @@ -0,0 +1,132 @@ +package io.micronaut.openapi.test.filter; + +import io.micronaut.core.annotation.Nullable; + +import java.util.Arrays; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * A type for specifying result filtering. + *

This is used to demonstrate custom parameter mapping, as it is mapped from the + * Filter header and bound with a custom binder.

+ * + * @param conditions The filtering conditions + */ +public record MyFilter ( + List conditions +) { + + private static final String CONDITION_REGEX = "^(.+)([<>=])(.+)$"; + private static final Pattern CONDITION_PATTERN = Pattern.compile(CONDITION_REGEX); + + /** + * An implementation with no filtering. + */ + public static final MyFilter EMPTY = new MyFilter(List.of()); + + /** + * Parse the filter from a query parameter. + * + * @param value the string representation of filter. + * @return the filter. + */ + public static MyFilter parse(@Nullable String value) { + if (value == null) { + return EMPTY; + } + List conditions = Arrays.stream(value.split(",")) + .map(Condition::parse) + .collect(Collectors.toList()); + return new MyFilter(conditions); + } + + @Override + public String toString() { + return conditions.stream().map(Object::toString).collect(Collectors.joining(",")); + } + + /** + * A filtering condition. + * + * @param propertyName the parameter to use for filtering + * @param comparator the filtering comparator + * @param value the value to compare with + */ + public record Condition( + String propertyName, + ConditionComparator comparator, + Object value + ) { + /** + * Parse the condition from a string representation. + * + * @param string the string + * @return the parsed condition + */ + public static Condition parse(String string) { + Matcher matcher = CONDITION_PATTERN.matcher(string); + if (matcher.find()) { + return new Condition( + matcher.group(1), + ConditionComparator.parse(matcher.group(2)), + matcher.group(3) + ); + } else { + throw new ParseException("The filter condition must match '" + CONDITION_REGEX + + "' but is '" + string + "'"); + } + } + + @Override + public String toString() { + return propertyName + comparator + value; + } + } + + /** + * An enum value for specifying how to compare in the condition. + */ + public enum ConditionComparator { + EQUALS("="), + GREATER_THAN(">"), + LESS_THAN("<"); + + private final String representation; + + ConditionComparator(String representation) { + this.representation = representation; + } + + /** + * Parse the condition comparator from string representation. + * + * @param string the string + * @return the comparator + */ + public static ConditionComparator parse(String string) { + return Arrays.stream(values()) + .filter(v -> v.representation.equals(string)) + .findFirst() + .orElseThrow( + () -> new ParseException("Condition comparator not supported: '" + string + "'") + ); + } + + @Override + public String toString() { + return representation; + } + } + + /** + * A custom exception for failed parsing + */ + public static class ParseException extends RuntimeException { + private ParseException(String message) { + super(message); + } + } +} diff --git a/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/filter/MyFilterBinder.java b/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/filter/MyFilterBinder.java new file mode 100644 index 0000000000..faeb6bda96 --- /dev/null +++ b/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/filter/MyFilterBinder.java @@ -0,0 +1,42 @@ +package io.micronaut.openapi.test.filter; + +import io.micronaut.core.convert.ArgumentConversionContext; +import io.micronaut.core.type.Argument; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.bind.binders.TypedRequestArgumentBinder; +import io.micronaut.http.exceptions.HttpStatusException; +import jakarta.inject.Singleton; + +import java.util.Optional; + +/** + * A custom parameter binder for MyFilter parameter type. + */ +@Singleton +final class MyFilterBinder implements TypedRequestArgumentBinder { + + public static final String HEADER_NAME = "Filter"; + + @Override + public Argument argumentType() { + return Argument.of(MyFilter.class); + } + + @Override + public BindingResult bind( + ArgumentConversionContext context, + HttpRequest source + ) { + String filter = source.getHeaders().get(HEADER_NAME); + return () -> { + try { + return Optional.of(MyFilter.parse(filter)); + } catch (MyFilter.ParseException e) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, + "Could not parse the " + HEADER_NAME + " query parameter. " + e.getMessage()); + } + }; + } + +} diff --git a/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/page/PageBodyWriter.java b/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/page/PageBodyWriter.java new file mode 100644 index 0000000000..65a21a12b2 --- /dev/null +++ b/test-suite-server-generator/src/main/java/io/micronaut/openapi/test/page/PageBodyWriter.java @@ -0,0 +1,98 @@ +package io.micronaut.openapi.test.page; + + +import io.micronaut.context.exceptions.ConfigurationException; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.annotation.Order; +import io.micronaut.core.type.Argument; +import io.micronaut.core.type.MutableHeaders; +import io.micronaut.data.model.Page; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.body.MessageBodyHandlerRegistry; +import io.micronaut.http.body.MessageBodyWriter; +import io.micronaut.http.codec.CodecException; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import java.io.OutputStream; +import java.util.List; + +/** + * An class for writing {@link Page} to the HTTP response with content as JSON body. + * + * @param the type of page item + */ +@Singleton +@Produces(MediaType.APPLICATION_JSON) +@Order(-1) +final class PageBodyWriter implements MessageBodyWriter> { + + private static final String PAGE_NUMBER_HEADER = "X-Page-Number"; + private static final String PAGE_SIZE_HEADER = "X-Page-Size"; + private static final String TOTAL_COUNT_HEADER = "X-Total-Count"; + private static final String PAGE_COUNT_HEADER = "X-Page-Count"; + + private final MessageBodyHandlerRegistry registry; + private final MessageBodyWriter> bodyWriter; + private final Argument> bodyType; + + @Inject + PageBodyWriter(MessageBodyHandlerRegistry registry) { + this(registry, null, null); + } + + private PageBodyWriter( + MessageBodyHandlerRegistry registry, + @Nullable MessageBodyWriter> bodyWriter, + @Nullable Argument> bodyType + ) { + this.registry = registry; + this.bodyWriter = bodyWriter; + this.bodyType = bodyType; + } + + @Override + public MessageBodyWriter> createSpecific( + Argument> type + ) { + Argument> bt = Argument.listOf(type.getTypeParameters()[0]); + MessageBodyWriter> writer = registry.findWriter(bt, List.of(MediaType.APPLICATION_JSON_TYPE)) + .orElseThrow(() -> new ConfigurationException("No JSON message writer present")); + return new PageBodyWriter<>(registry, writer, bt); + } + + @Override + public void writeTo( + Argument> type, + MediaType mediaType, + Page page, + MutableHeaders headers, + OutputStream outputStream + ) throws CodecException { + if (bodyType != null && bodyWriter != null) { + headers.add(PAGE_NUMBER_HEADER, String.valueOf(page.getPageNumber())); + headers.add(PAGE_SIZE_HEADER, String.valueOf(page.getSize())); + headers.add(PAGE_COUNT_HEADER, String.valueOf(page.getTotalPages())); + headers.add(TOTAL_COUNT_HEADER, String.valueOf(page.getTotalSize())); + + bodyWriter.writeTo(bodyType, mediaType, page.getContent(), headers, outputStream); + } else { + throw new ConfigurationException("No JSON message writer present"); + } + } + + @Override + public boolean isWriteable( + Argument> type, + MediaType mediaType + ) { + return bodyType != null && bodyWriter != null && bodyWriter.isWriteable(bodyType, mediaType); + } + + @Override + public boolean isBlocking() { + return bodyWriter != null && bodyWriter.isBlocking(); + } + +} diff --git a/test-suite-server-generator/src/test/groovy/io/micronaut/openapi/test/api/ParametersControllerSpec.groovy b/test-suite-server-generator/src/test/groovy/io/micronaut/openapi/test/api/ParametersControllerSpec.groovy index 27f519913a..3fae745625 100644 --- a/test-suite-server-generator/src/test/groovy/io/micronaut/openapi/test/api/ParametersControllerSpec.groovy +++ b/test-suite-server-generator/src/test/groovy/io/micronaut/openapi/test/api/ParametersControllerSpec.groovy @@ -123,11 +123,36 @@ class ParametersControllerSpec extends Specification { void "test send page query"() { when: HttpRequest request = - HttpRequest.GET("/sendPageQuery?page=2&size=20&sort=my-property%20desc,my-property-2") + HttpRequest.GET("/sendPageQuery?page=2&size=20&sort=my-property,desc&sort=my-property-2") String response = client.retrieve(request, Argument.of(String), Argument.of(String)) then: - "(page: 2, size: 20, sort: my-property desc,my-property-2)" == response + "(page: 2, size: 20, sort: my-property(dir=DESC) my-property-2(dir=ASC))" == response + } + + void "test send mapped parameter"() { + given: + var filter = "name=Andrew,age>20" + + when: + HttpRequest request = + HttpRequest.GET("/sendMappedParameter") + .header("Filter", filter) + String response = client.retrieve(request, Argument.of(String), Argument.of(String)) + + then: + response == filter + } + + void "test send invalid mapped parameter"() { + when: + HttpRequest request = HttpRequest.GET("/sendMappedParameter") + .header("Filter", "name=Andrew,age>20,something-else") + client.retrieve(request, Argument.of(String), Argument.of(String)) + + then: + var e = thrown(HttpClientResponseException) + HttpStatus.BAD_REQUEST == e.status } } diff --git a/test-suite-server-generator/src/test/groovy/io/micronaut/openapi/test/api/ResponseBodyControllerSpec.groovy b/test-suite-server-generator/src/test/groovy/io/micronaut/openapi/test/api/ResponseBodyControllerSpec.groovy index fc58fa786c..0f7a7bda65 100644 --- a/test-suite-server-generator/src/test/groovy/io/micronaut/openapi/test/api/ResponseBodyControllerSpec.groovy +++ b/test-suite-server-generator/src/test/groovy/io/micronaut/openapi/test/api/ResponseBodyControllerSpec.groovy @@ -41,24 +41,38 @@ class ResponseBodyControllerSpec extends Specification { ResponseBodyController.SIMPLE_MODEL == response.body() } - // TODO implement the behavior and test void "test get paginated simple model"() { + given: + var page = "12" + var pageSize = "10" + when: + HttpRequest request = HttpRequest.GET("/getPaginatedSimpleModel?page=${page}&size=${pageSize}") HttpResponse> response = - client.exchange(HttpRequest.GET("/getPaginatedSimpleModel"), Argument.listOf(SimpleModel)) + client.exchange(request, Argument.listOf(SimpleModel)) then: + var totalCount = "3" + var pageCount = "1" + HttpStatus.OK == response.status ResponseBodyController.SIMPLE_MODELS == response.body() + page == response.header("X-Page-Number") + totalCount == response.header("X-Total-Count") + 3 == response.body().size() + pageSize == response.header("X-Page-Size") + pageCount == response.header("X-Page-Count") } - // TODO implement the behavior and test void "test get dated simple model"() { + when: HttpResponse response = client.exchange(HttpRequest.GET("/getDatedSimpleModel"), Argument.of(SimpleModel)) - HttpStatus.ACCEPTED == response.status() + then: + HttpStatus.OK == response.status() ResponseBodyController.SIMPLE_MODEL == response.body() + ResponseBodyController.LAST_MODIFIED_STRING == response.header("Last-Modified") } void "test get simple model with non standard status"() {