From 19c5e86d4a42aeae6f42f6f8e891a68ab7206bed Mon Sep 17 00:00:00 2001 From: joseegarcia Date: Tue, 29 Sep 2026 16:24:21 +0200 Subject: [PATCH 1/2] Generate every OpenAPI 3.2 operation, and stop dropping 3.2 contract parts silently Operations: head, options and trace were dropped, and so were OpenAPI 3.2's query and additionalOperations. Every Path Item operation is now generated. Servers map the methods Spring's RequestMethod has no constant for (QUERY, PURGE...) with a generated @HttpMethodMapping, which a generated HttpMethodMappingConfiguration maps through a RequestMappingHandlerMapping of its own (Spring MVC and WebFlux, Boot 2 to 4): it maps only those methods, so Spring still answers the others, 405 included, and it answers 406/415 for a request of its method it cannot serve. Such operations declare what they consume, since Spring reads a @RequestBody's content type only for POST/PUT/PATCH. Clients send them with HttpMethod.valueOf; Spring Boot 2 clients, whose HttpMethod is an enum, fail the generation instead. Also: - an in: querystring parameter, a form-encoded schema of the whole query string, is read as that object sent form-exploded (any other media type fails); clients used to take it and never send it; - a $ref to components/mediaTypes is resolved (it crashed the generator); - a response without description is documented with its summary; - application/json-seq and text/event-stream fail the generation, as their items were read as a single JSON document. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 54 +++ .../plugin/openapi/OpenApiGenerator.java | 18 + .../plugin/openapi/model/OperationObject.java | 6 + .../template/ClasspathTemplateLoader.java | 4 +- .../openapi/template/TemplateFactory.java | 16 +- .../template/TemplateIndexConstants.java | 4 + .../plugin/openapi/utils/MapperPathUtil.java | 42 ++- .../plugin/openapi/utils/OpenApiUtil.java | 114 +++++- .../resources/templates/openapi/template.ftlh | 23 +- .../openapi/templateCallRestClient.ftlh | 2 +- .../openapi/templateCallWebClient.ftlh | 2 +- .../openapi/templateHttpMethodMapping.ftlh | 31 ++ ...emplateHttpMethodMappingConfiguration.ftlh | 167 +++++++++ .../templates/openapi/templateReactive.ftlh | 8 +- .../openapi/OpenApiGeneratorFixtures.java | 67 ++++ .../openapi/OpenApiGeneratorJakartaTest.java | 2 + .../OpenApiGeneratorSpringBoot4Test.java | 8 + .../plugin/openapi/OpenApiGeneratorTest.java | 35 ++ .../testOpenApi32/api-json-seq.yml | 19 + .../testOpenApi32/api-querystring-json.yml | 22 ++ .../testOpenApi32/api-sse.yml | 19 + .../testOpenApi32/api-test.yml | 108 ++++++ .../assets/httpexchange/ItemsApi.java | 81 +++++ .../imperative-boot4/HttpMethodMapping.java | 30 ++ .../HttpMethodMappingConfiguration.java | 108 ++++++ .../assets/imperative-boot4/ItemsApi.java | 159 +++++++++ .../assets/imperative/CriteriaDTO.java | 94 +++++ .../assets/imperative/FilterDTO.java | 76 ++++ .../assets/imperative/HttpMethodMapping.java | 30 ++ .../HttpMethodMappingConfiguration.java | 108 ++++++ .../assets/imperative/ItemDTO.java | 94 +++++ .../assets/imperative/ItemsApi.java | 159 +++++++++ .../reactive-boot3/HttpMethodMapping.java | 30 ++ .../HttpMethodMappingConfiguration.java | 111 ++++++ .../assets/reactive-boot3/ItemsApi.java | 163 +++++++++ .../assets/reactive/HttpMethodMapping.java | 30 ++ .../HttpMethodMappingConfiguration.java | 111 ++++++ .../assets/reactive/ItemsApi.java | 163 +++++++++ .../assets/restclient/ItemsApi.java | 265 ++++++++++++++ .../assets/webclient/ItemsApi.java | 331 ++++++++++++++++++ 40 files changed, 2877 insertions(+), 37 deletions(-) create mode 100644 multiapi-engine/src/main/resources/templates/openapi/templateHttpMethodMapping.ftlh create mode 100644 multiapi-engine/src/main/resources/templates/openapi/templateHttpMethodMappingConfiguration.ftlh create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/api-json-seq.yml create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/api-querystring-json.yml create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/api-sse.yml create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/api-test.yml create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/httpexchange/ItemsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative-boot4/HttpMethodMapping.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative-boot4/HttpMethodMappingConfiguration.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative-boot4/ItemsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/CriteriaDTO.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/FilterDTO.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/HttpMethodMapping.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/HttpMethodMappingConfiguration.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/ItemDTO.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/ItemsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive-boot3/HttpMethodMapping.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive-boot3/HttpMethodMappingConfiguration.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive-boot3/ItemsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive/HttpMethodMapping.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive/HttpMethodMappingConfiguration.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive/ItemsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/restclient/ItemsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/webclient/ItemsApi.java diff --git a/README.md b/README.md index 10d94f05..93962fa9 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Maven and Gradle - [Calling an API from your service (callMode)](#calling-an-api-from-your-service-callmode) - [Object-typed query parameters and multipart bodies](#object-typed-query-parameters-and-multipart-bodies) - [Streaming endpoints (NDJSON and JSON Lines)](#streaming-endpoints-ndjson-and-json-lines) + - [OpenAPI 3.2 operations](#openapi-32-operations) - [Camel case Java names (useCamelCaseNames)](#camel-case-java-names-usecamelcasenames) - [Unknown enum values (useUnknownEnumValue)](#unknown-enum-values-useunknownenumvalue) - [Property Validation](#property-validation) @@ -1183,6 +1184,59 @@ for it. the generation fails for a streaming operation: generate that client with `reactive` on. +### OpenAPI 3.2 operations + +Every operation a Path Item declares is generated: `get`, `put`, `post`, +`delete`, `patch`, `head`, `options` and `trace`, OpenAPI 3.2's `query`, and the +methods of its `additionalOperations` map, such as `PURGE`. + +Spring's `@RequestMapping` only takes the methods of `RequestMethod`, so the +server interfaces map `QUERY` and the `additionalOperations` methods with a +generated annotation instead: + +```java +@HttpMethodMapping(method = "QUERY", value = "/items", + produces = {"application/json"}, consumes = {"application/json"}) +default ResponseEntity> queryItems( + @Valid @RequestBody FilterDTO filterDTO) { +``` + +The interfaces are implemented as usual. The generated +`HttpMethodMappingConfiguration`, next to them, adds a handler mapping of those +operations, and of nothing else, next to Spring MVC's or WebFlux's own, so it +does not interfere with the rest of the application's configuration. It is +picked up by component scanning when the API package is under the +application's; otherwise add `@Import(HttpMethodMappingConfiguration.class)`. +Other methods of the same paths are still answered by Spring, 405 included. + +- The clients send any method on Spring Boot 3 or later. Spring Framework 5's + `HttpMethod` is an enum of the methods it knows, so the generation of a Spring + Boot 2 client fails for `QUERY` or an `additionalOperations` method. +- The generated RestClient class sends them through an `ApiRestClient` built on + a `RestClient`, or on a `RestTemplate` whose request factory supports them + (JDK `HttpClient`, Apache HttpComponents, Jetty, Reactor Netty). The default + `RestTemplate` factory, the JDK's `HttpURLConnection`, rejects them. +- springdoc documents the operations Spring maps, so the `@HttpMethodMapping` + ones are missing from the OpenAPI document it serves. +- Spring MVC does not dispatch `TRACE` requests unless + `spring.mvc.dispatch-trace-request` is on, and Tomcat refuses them unless its + connector allows them. + +The other OpenAPI 3.2 additions that change the generated code: + +- A parameter `in: querystring`, whose `application/x-www-form-urlencoded` + schema describes the whole query string, is generated as that object sent + `form`-exploded: one argument per property in the server and `@HttpExchange` + interfaces, and the object, sent as its properties, in the client classes. Any + other media type fails the generation. +- A media type declared as a `$ref` to `components/mediaTypes` is resolved. +- A response without `description` is documented with its `summary`. +- `application/json-seq` and `text/event-stream` fail the generation, since + their items would be read as a single JSON document. + +`$self`, the new Tag, Server and Example fields, and the new security scheme +fields do not change the generated code. + ### Camel case Java names (useCamelCaseNames) By default the generated Java code uses the names the contract gives, whenever diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/OpenApiGenerator.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/OpenApiGenerator.java index 8c24a2a8..e1e7d5f3 100644 --- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/OpenApiGenerator.java +++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/OpenApiGenerator.java @@ -39,6 +39,7 @@ import com.sngular.api.generator.plugin.openapi.exception.DuplicateModelClassException; import com.sngular.api.generator.plugin.openapi.model.AuthObject; import com.sngular.api.generator.plugin.openapi.model.GlobalObject; +import com.sngular.api.generator.plugin.openapi.model.OperationObject; import com.sngular.api.generator.plugin.openapi.model.PathObject; import com.sngular.api.generator.plugin.openapi.parameter.SpecFile; import com.sngular.api.generator.plugin.openapi.template.TemplateFactory; @@ -200,6 +201,9 @@ private void processFile(final SpecFile specFile) { // Determine the actual base URI for resolving external references final URI specBaseUri = resolveSpecBaseUri(specFile); OpenApiUtil.solvePathRefs(openAPI, specBaseUri); + OpenApiUtil.flattenAdditionalOperations(openAPI); + OpenApiUtil.resolveMediaTypeRefs(openAPI); + OpenApiUtil.normalizeQuerystringParameters(openAPI); OpenApiUtil.promoteItemSchemas(openAPI); final String clientPackage = specFile.getClientPackage(); @@ -268,6 +272,7 @@ private GlobalObject createApiTemplate(final SpecFile specFile, final JsonNode o final String javaFileName = OpenApiUtil.processJavaFileName(apisKey); final List pathObjects = MapperPathUtil.mapPathObjects(specFile, apis.get(apisKey), globalObject, baseDir); final AuthObject authObject = MapperAuthUtil.getApiAuthObject(globalObject.getAuthSchemas(), pathObjects); + validateCustomMethods(specFile, pathObjects); try { templateFactory.fillTemplate(specFile, javaFileName, pathObjects, authObject); @@ -283,6 +288,19 @@ private GlobalObject createApiTemplate(final SpecFile specFile, final JsonNode o return globalObject; } + /** + * Spring Framework 5's {@code HttpMethod} is an enum of the methods it knows, so clients generated for Spring Boot 2 cannot + * send any other one, such as OpenAPI 3.2's {@code QUERY}. + */ + private void validateCustomMethods(final SpecFile specFile, final List pathObjects) { + if (specFile.isCallMode() && !bootVersion.isAtLeast(3, 0)) { + pathObjects.stream().flatMap(path -> path.getOperationObjects().stream()).filter(OperationObject::isCustomMethod).findFirst().ifPresent(operation -> { + throw new CodeGenerationException("Operation '" + operation.getOperationId() + "' uses the HTTP method " + operation.getOperationType() + + ", which Spring Framework 5 clients cannot send: generate its client with springBootVersion 3 or later"); + }); + } + } + private void createModelTemplate(final SpecFile specFile, final JsonNode openAPI, final GlobalObject globalObject) { final var modelPackage = processModelPackage(specFile.getApiPackage(), specFile.getModelPackage()); diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/model/OperationObject.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/model/OperationObject.java index d1a7d51e..6065234f 100644 --- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/model/OperationObject.java +++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/model/OperationObject.java @@ -22,6 +22,12 @@ public final class OperationObject { private String operationType; + /** + * Whether the HTTP method is one Spring's {@code RequestMethod} has no constant for, such as OpenAPI 3.2's {@code QUERY}, so + * servers map it through the generated {@code @HttpMethodMapping}. + */ + private boolean customMethod; + private String summary; private String operationId; diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/template/ClasspathTemplateLoader.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/template/ClasspathTemplateLoader.java index d5e21161..fe0c1435 100644 --- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/template/ClasspathTemplateLoader.java +++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/template/ClasspathTemplateLoader.java @@ -21,7 +21,9 @@ public class ClasspathTemplateLoader extends CommonTemplateLoader { private static final List TEMPLATE_FILES = List.of(TemplateIndexConstants.TEMPLATE_INTERFACE_API, TemplateIndexConstants.TEMPLATE_CALL_WEB_API, TemplateIndexConstants.TEMPLATE_WEB_CLIENT, TemplateIndexConstants.TEMPLATE_CALL_REST_API, TemplateIndexConstants.TEMPLATE_REST_CLIENT, TemplateIndexConstants.TEMPLATE_REACTIVE_API, - TemplateIndexConstants.TEMPLATE_CALL_HTTP_EXCHANGE_API, TemplateIndexConstants.TEMPLATE_NDJSON_SUPPORT); + TemplateIndexConstants.TEMPLATE_CALL_HTTP_EXCHANGE_API, TemplateIndexConstants.TEMPLATE_NDJSON_SUPPORT, + TemplateIndexConstants.TEMPLATE_HTTP_METHOD_MAPPING, + TemplateIndexConstants.TEMPLATE_HTTP_METHOD_MAPPING_CONFIGURATION); private static final List TEMPLATE_AUTH_FILES = List.of(TemplateIndexConstants.TEMPLATE_API_KEY, TemplateIndexConstants.TEMPLATE_AUTHENTICATION, TemplateIndexConstants.TEMPLATE_HTTP_BASIC, TemplateIndexConstants.TEMPLATE_HTTP_BEARER, diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/template/TemplateFactory.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/template/TemplateFactory.java index c9bf7de2..e7491eb7 100644 --- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/template/TemplateFactory.java +++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/template/TemplateFactory.java @@ -13,6 +13,7 @@ import com.sngular.api.generator.plugin.common.template.CommonTemplateFactory; import com.sngular.api.generator.plugin.openapi.model.AuthObject; +import com.sngular.api.generator.plugin.openapi.model.OperationObject; import com.sngular.api.generator.plugin.openapi.model.PathObject; import com.sngular.api.generator.plugin.openapi.model.RequestObject; import com.sngular.api.generator.plugin.openapi.parameter.SpecFile; @@ -81,21 +82,30 @@ public final void fillTemplate( addToRoot("exceptionPackage", specFile.getModelPackage()); } + addToRoot("isReactive", specFile.isReactive()); if (specFile.isCallMode()) { addToRoot("authObject", authObject); addToRoot("clientPackage", specFile.getClientPackage()); addToRoot("clientComponent", specFile.shouldRegisterClientComponent()); - addToRoot("isReactive", specFile.isReactive()); } writeTemplateToFile(specFile.isCallMode() ? getTemplateClientApi(specFile) : getTemplateApi(specFile), StringUtils.defaultIfEmpty(specFile.getApiPackage(), DEFAULT_API_PACKAGE), className + "Api"); + final String apiPackage = StringUtils.defaultIfEmpty(specFile.getApiPackage(), DEFAULT_API_PACKAGE); // Spring MVC interfaces send and read application/x-ndjson through a helper written next to them. if (!specFile.isCallMode() && !specFile.isReactive() && streams(pathObjects)) { - writeTemplateToFile(TemplateIndexConstants.TEMPLATE_NDJSON_SUPPORT, StringUtils.defaultIfEmpty(specFile.getApiPackage(), DEFAULT_API_PACKAGE), - "NdjsonSupport"); + writeTemplateToFile(TemplateIndexConstants.TEMPLATE_NDJSON_SUPPORT, apiPackage, "NdjsonSupport"); } + // Servers map the HTTP methods Spring's RequestMethod has no constant for through an annotation and a handler mapping of their own. + if (!specFile.isCallMode() && usesCustomMethods(pathObjects)) { + writeTemplateToFile(TemplateIndexConstants.TEMPLATE_HTTP_METHOD_MAPPING, apiPackage, "HttpMethodMapping"); + writeTemplateToFile(TemplateIndexConstants.TEMPLATE_HTTP_METHOD_MAPPING_CONFIGURATION, apiPackage, "HttpMethodMappingConfiguration"); + } + } + + public static boolean usesCustomMethods(final List pathObjects) { + return pathObjects.stream().flatMap(path -> path.getOperationObjects().stream()).anyMatch(OperationObject::isCustomMethod); } private static boolean streams(final List pathObjects) { diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/template/TemplateIndexConstants.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/template/TemplateIndexConstants.java index dc72faed..a6ab0e56 100644 --- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/template/TemplateIndexConstants.java +++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/template/TemplateIndexConstants.java @@ -22,6 +22,10 @@ public class TemplateIndexConstants extends CommonTemplateIndexConstants { public static final String TEMPLATE_NDJSON_SUPPORT = "templateNdjsonSupport.ftlh"; + public static final String TEMPLATE_HTTP_METHOD_MAPPING = "templateHttpMethodMapping.ftlh"; + + public static final String TEMPLATE_HTTP_METHOD_MAPPING_CONFIGURATION = "templateHttpMethodMappingConfiguration.ftlh"; + public static final String TEMPLATE_REST_CLIENT = "templateRestClient.ftlh"; public static final String TEMPLATE_WEB_CLIENT = "templateWebClient.ftlh"; diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/MapperPathUtil.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/MapperPathUtil.java index b87aa4a4..5bd8a8f1 100644 --- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/MapperPathUtil.java +++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/MapperPathUtil.java @@ -71,6 +71,19 @@ public class MapperPathUtil { private static final String JSON_MEDIA_TYPE = "application/json"; + /** + * Sequential media types OpenAPI 3.2 names that are not generated yet, whose items would otherwise be read as a single JSON + * document. + */ + private static final Set UNSUPPORTED_SEQUENTIAL_MEDIA_TYPES = Set.of("application/json-seq", "text/event-stream"); + + /** + * The HTTP methods Spring's {@code RequestMethod} and Spring Framework 5's {@code HttpMethod} name. Any other one, such as + * OpenAPI 3.2's {@code QUERY} or an {@code additionalOperations} method, is mapped through the generated + * {@code @HttpMethodMapping}. + */ + private static final Set SPRING_REQUEST_METHODS = Set.of("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"); + private MapperPathUtil() { } @@ -137,19 +150,10 @@ private static List mapOperationObject(final SpecFile specFile, final var pathParameters = new ArrayList(); for (final Iterator> it = pathNode.fields(); it.hasNext(); ) { final var field = it.next(); - switch (field.getKey()) { - case "get": - case "post": - case "delete": - case "put": - case "patch": - operationObjects.add(createOperation(field.getValue(), field.getKey().toUpperCase(), specFile, globalObject, operationIdList, baseDir)); - break; - case "parameters": - pathParameters.addAll(mapParameterObjects(IteratorUtils.toList(field.getValue().elements()), specFile, null, globalObject, baseDir)); - break; - default: - break; + if (OpenApiUtil.isOperation(field.getKey())) { + operationObjects.add(createOperation(field.getValue(), OpenApiUtil.httpMethod(field.getKey()), specFile, globalObject, operationIdList, baseDir)); + } else if ("parameters".equals(field.getKey())) { + pathParameters.addAll(mapParameterObjects(IteratorUtils.toList(field.getValue().elements()), specFile, null, globalObject, baseDir)); } } if (!pathParameters.isEmpty()) { @@ -169,6 +173,7 @@ private static OperationObject createOperation( final OperationObject operationObject = OperationObject.builder() .operationId(mapOperationId(getOperationId(operation), operationIdList)) .operationType(operationType) + .customMethod(!SPRING_REQUEST_METHODS.contains(operationType)) .summary(ApiTool.getNodeAsString(operation, "summary")) .tags(elementsToStrList(Objects.nonNull(tagsNode) ? tagsNode.elements() : null)) .requestObjects(mapRequestObject(specFile, operation, globalObject, baseDir)) @@ -193,6 +198,12 @@ public static boolean isStreamingMediaType(final String mediaType) { * content. */ private static void applyStreaming(final OperationObject operation, final SpecFile specFile) { + for (final String mediaType : UNSUPPORTED_SEQUENTIAL_MEDIA_TYPES) { + if (operation.getProduces().contains(mediaType) || operation.getConsumes().contains(mediaType)) { + throw new CodeGenerationException("Operation '" + operation.getOperationId() + "' declares " + mediaType + ", a sequential media type that is " + + "not generated yet: its items would be read as a single JSON document"); + } + } if (operation.getProduces().contains(STREAM_JSON_MEDIA_TYPE) || operation.getConsumes().contains(STREAM_JSON_MEDIA_TYPE)) { throw new CodeGenerationException("Operation '" + operation.getOperationId() + "' declares " + STREAM_JSON_MEDIA_TYPE + ", which is deprecated and " + "which Spring Framework 7 no longer streams: declare the stream as application/x-ndjson or application/jsonl"); @@ -560,7 +571,10 @@ private static void buildResponse( } final String operationIdWithCap = operationId.substring(0, 1).toUpperCase() + operationId.substring(1); final JsonNode content = Objects.nonNull(realResponse) ? ApiTool.getNode(realResponse, CONTENT) : null; - final String description = Objects.nonNull(realResponse) ? StringUtils.defaultIfEmpty(ApiTool.getNodeAsString(realResponse, DESCRIPTION), "") : ""; + // OpenAPI 3.2 makes the description optional, next to a short summary. + final String description = Objects.nonNull(realResponse) + ? StringUtils.defaultIfEmpty(ApiTool.getNodeAsString(realResponse, DESCRIPTION), StringUtils.defaultString(ApiTool.getNodeAsString(realResponse, "summary"))) + : ""; responseObjects.add(ResponseObject .builder() .responseName(responseCode) diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/OpenApiUtil.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/OpenApiUtil.java index 4898057f..d17f67f9 100644 --- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/OpenApiUtil.java +++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/utils/OpenApiUtil.java @@ -26,6 +26,7 @@ import com.sngular.api.generator.plugin.common.tools.MapperUtil; import com.sngular.api.generator.plugin.common.tools.SchemaUtil; import com.sngular.api.generator.plugin.common.tools.StringCaseUtils; +import com.sngular.api.generator.plugin.openapi.exception.CodeGenerationException; import com.sngular.api.generator.plugin.openapi.parameter.SpecFile; import org.apache.commons.collections4.IteratorUtils; import org.apache.commons.collections4.MultiValuedMap; @@ -38,7 +39,24 @@ public class OpenApiUtil { public static final String WEBHOOKS = "webhooks"; - static final Set REST_VERB_SET = Set.of("get", "post", "delete", "patch", "put"); + /** The operations a Path Item declares by their HTTP method, {@code query} being OpenAPI 3.2's. */ + static final Set REST_VERB_SET = Set.of("get", "post", "delete", "patch", "put", "head", "options", "trace", "query"); + + /** + * The key an operation of the OpenAPI 3.2 {@code additionalOperations} map is moved to in its Path Item, followed by its + * method as the contract spells it, so it is read as the other operations are. + */ + static final String ADDITIONAL_OPERATION_PREFIX = "x-additional-operation-"; + + private static final String ADDITIONAL_OPERATIONS = "additionalOperations"; + + private static final String MEDIA_TYPES_REF = "#/components/mediaTypes/"; + + private static final int MAX_MEDIA_TYPE_REF_DEPTH = 16; + + private static final String CONTENT = "content"; + + private static final String FORM_URLENCODED = "application/x-www-form-urlencoded"; private OpenApiUtil() { } @@ -79,7 +97,7 @@ private static MultiValuedMap> mapApiGroupsByUrl(f private static MultiValuedMap> getMapMethodsByTag(final Entry pathItem) { final MultiValuedMap> mapByTag = new ArrayListValuedHashMap<>(); - final var operations = IteratorUtils.filteredIterator(pathItem.getValue().fields(), opProperty -> REST_VERB_SET.contains(opProperty.getKey())); + final var operations = IteratorUtils.filteredIterator(pathItem.getValue().fields(), opProperty -> isOperation(opProperty.getKey())); while (operations.hasNext()) { final var method = operations.next(); if (ApiTool.hasNode(method.getValue(), "tags")) { @@ -94,7 +112,7 @@ private static ObjectNode buildTaggedPathItem(final JsonNode pathItem, final Ent final var taggedPathItem = JsonNodeFactory.instance.objectNode(); taggedPathItem.set(method.getKey(), method.getValue()); pathItem.fields().forEachRemaining(field -> { - if (!REST_VERB_SET.contains(field.getKey()) && !taggedPathItem.has(field.getKey())) { + if (!isOperation(field.getKey()) && !taggedPathItem.has(field.getKey())) { taggedPathItem.set(field.getKey(), field.getValue()); } }); @@ -134,6 +152,92 @@ public static void mergeWebhooksIntoPaths(final JsonNode openApi) { } } + /** Whether a Path Item field is an operation. */ + public static boolean isOperation(final String pathItemField) { + return REST_VERB_SET.contains(pathItemField) || StringUtils.startsWith(pathItemField, ADDITIONAL_OPERATION_PREFIX); + } + + /** The HTTP method of the operation a Path Item declares under the given field. */ + public static String httpMethod(final String pathItemField) { + return StringUtils.startsWith(pathItemField, ADDITIONAL_OPERATION_PREFIX) + ? pathItemField.substring(ADDITIONAL_OPERATION_PREFIX.length()) + : pathItemField.toUpperCase(Locale.ROOT); + } + + /** + * Moves each operation of an OpenAPI 3.2 {@code additionalOperations} map (HTTP methods OpenAPI has no field for, such + * as {@code PURGE}) into its Path Item, so it is grouped and generated as the other operations are. + */ + public static void flattenAdditionalOperations(final JsonNode openApi) { + final JsonNode paths = openApi.get(PATHS); + if (paths instanceof ObjectNode) { + paths.elements().forEachRemaining(pathItem -> { + if (pathItem instanceof ObjectNode && pathItem.get(ADDITIONAL_OPERATIONS) instanceof ObjectNode) { + final JsonNode additional = ((ObjectNode) pathItem).remove(ADDITIONAL_OPERATIONS); + additional.fields().forEachRemaining(operation -> ((ObjectNode) pathItem).set(ADDITIONAL_OPERATION_PREFIX + operation.getKey(), operation.getValue())); + } + }); + } + } + + /** + * Replaces each Media Type Object that is a {@code $ref} to an OpenAPI 3.2 {@code components/mediaTypes} entry by that + * entry, so its schema is read where it is used. + */ + public static void resolveMediaTypeRefs(final JsonNode openApi) { + final JsonNode mediaTypes = openApi.path("components").path("mediaTypes"); + if (mediaTypes instanceof ObjectNode) { + resolveMediaTypeRefs(openApi, (ObjectNode) mediaTypes, 0); + } + } + + private static void resolveMediaTypeRefs(final JsonNode node, final ObjectNode mediaTypes, final int depth) { + if (node instanceof ObjectNode && node.get(CONTENT) instanceof ObjectNode) { + final ObjectNode content = (ObjectNode) node.get(CONTENT); + content.fieldNames().forEachRemaining(mediaType -> content.set(mediaType, resolveMediaType(content.get(mediaType), mediaTypes, depth))); + } + if (Objects.nonNull(node) && node.isContainerNode()) { + node.elements().forEachRemaining(child -> resolveMediaTypeRefs(child, mediaTypes, depth)); + } + } + + private static JsonNode resolveMediaType(final JsonNode mediaType, final ObjectNode mediaTypes, final int depth) { + final String ref = ApiTool.hasRef(mediaType) ? ApiTool.getRefValue(mediaType) : null; + if (Objects.isNull(ref) || !ref.startsWith(MEDIA_TYPES_REF)) { + return mediaType; + } + final JsonNode resolved = mediaTypes.get(ref.substring(MEDIA_TYPES_REF.length())); + if (Objects.isNull(resolved) || depth > MAX_MEDIA_TYPE_REF_DEPTH) { + throw new CodeGenerationException("The media type " + ref + " is not declared in components/mediaTypes"); + } + return resolveMediaType(resolved.deepCopy(), mediaTypes, depth + 1); + } + + /** + * Declares each OpenAPI 3.2 {@code in: querystring} parameter, whose {@code application/x-www-form-urlencoded} schema + * describes the whole query string, as the {@code form}-exploded query object it serializes as: one query parameter per + * property. Server and {@code @HttpExchange} interfaces then take one argument per property, and the client classes + * send each of them, as for any exploded query object. + */ + public static void normalizeQuerystringParameters(final JsonNode node) { + if (node instanceof ObjectNode && "querystring".equals(ApiTool.getNodeAsString(node, "in"))) { + final ObjectNode parameter = (ObjectNode) node; + final JsonNode content = parameter.get(CONTENT); + if (Objects.isNull(content) || content.size() != 1 || !content.has(FORM_URLENCODED) || !content.get(FORM_URLENCODED).has("schema")) { + throw new CodeGenerationException("The querystring parameter '" + ApiTool.getName(parameter) + "' must describe the query string as a single " + + FORM_URLENCODED + " schema, but its content is " + (Objects.isNull(content) ? "missing" : content.toString())); + } + parameter.put("in", "query"); + parameter.set("schema", content.get(FORM_URLENCODED).get("schema")); + parameter.put("style", "form"); + parameter.put("explode", true); + parameter.remove(CONTENT); + } + if (Objects.nonNull(node) && node.isContainerNode()) { + node.elements().forEachRemaining(OpenApiUtil::normalizeQuerystringParameters); + } + } + /** * Declares the {@code itemSchema} of a media type (OpenAPI 3.2, for sequential media types such as * {@code application/x-ndjson}) as its {@code schema}, so the rest of the pipeline reads the type of each streamed item @@ -163,7 +267,7 @@ public static void promoteItemSchemas(final JsonNode node) { private static void defaultOperationTags(final JsonNode pathItem, final String defaultTag) { if (pathItem instanceof ObjectNode) { pathItem.fields().forEachRemaining(field -> { - if (REST_VERB_SET.contains(field.getKey()) && field.getValue() instanceof ObjectNode) { + if (isOperation(field.getKey()) && field.getValue() instanceof ObjectNode) { final ObjectNode operation = (ObjectNode) field.getValue(); if (!ApiTool.hasNode(operation, "tags") || !operation.get("tags").isArray() || operation.get("tags").isEmpty()) { operation.putArray("tags").add(defaultTag); @@ -213,7 +317,7 @@ public static Map processPaths(final JsonNode openApi, final M final var pathDefinition = pathElement.next(); for (Iterator it = pathDefinition.fieldNames(); it.hasNext(); ) { final var pathDefElement = it.next(); - if (REST_VERB_SET.contains(pathDefElement)) { + if (isOperation(pathDefElement)) { processPathContent(schemaMap, ApiTool.getNode(pathDefinition, pathDefElement), specFile); } } diff --git a/multiapi-engine/src/main/resources/templates/openapi/template.ftlh b/multiapi-engine/src/main/resources/templates/openapi/template.ftlh index dc8bef57..2507b6bc 100644 --- a/multiapi-engine/src/main/resources/templates/openapi/template.ftlh +++ b/multiapi-engine/src/main/resources/templates/openapi/template.ftlh @@ -68,8 +68,9 @@ * ${operation.operationType} ${path.pathName}<#if operation.summary?has_content>: ${operation.summary}, sending the items {@link #${operation.operationId}} returns as ${operation.streamingMediaType}, one JSON document per line. */ <@operationAnnotation path operation/> - @RequestMapping( - method = RequestMethod.${operation.operationType}, + <#if operation.customMethod>@HttpMethodMapping( + method = "${operation.operationType}",<#else>@RequestMapping( + method = RequestMethod.${operation.operationType}, value = "${path.pathName}", produces = {"${operation.streamingMediaType}"}${consumesStream} ) @@ -82,8 +83,9 @@ * ${operation.operationType} ${path.pathName}<#if operation.summary?has_content>: ${operation.summary}, sending the items {@link #${operation.operationId}} returns as a JSON array. */ <@operationAnnotation path operation/> - @RequestMapping( - method = RequestMethod.${operation.operationType}, + <#if operation.customMethod>@HttpMethodMapping( + method = "${operation.operationType}",<#else>@RequestMapping( + method = RequestMethod.${operation.operationType}, value = "${path.pathName}", produces = {"application/json"}${consumesStream} ) @@ -97,8 +99,9 @@ * ${operation.operationType} ${path.pathName}<#if operation.summary?has_content>: ${operation.summary}, reading the ${operation.consumes[0]} body, one JSON document per line, as the items {@link #${operation.operationId}} receives. */ <@operationAnnotation path operation/> - @RequestMapping( - method = RequestMethod.${operation.operationType}, + <#if operation.customMethod>@HttpMethodMapping( + method = "${operation.operationType}",<#else>@RequestMapping( + method = RequestMethod.${operation.operationType}, value = "${path.pathName}", produces = {"application/json"}${consumesStream} ) @@ -270,11 +273,13 @@ public interface ${className?cap_first}Api { } ) - @RequestMapping( - method = RequestMethod.${operation.operationType}, + <#if operation.customMethod>@HttpMethodMapping( + method = "${operation.operationType}",<#else>@RequestMapping( + method = RequestMethod.${operation.operationType}, value = "${path.pathName}", produces = {"application/json"}<#if operation.requestObjects?has_content && operation.requestObjects[0].isFormData>, - consumes = {MediaType.MULTIPART_FORM_DATA_VALUE} + consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}<#elseif operation.customMethod && operation.requestObjects?has_content && operation.consumes?has_content>, + consumes = {<#list operation.consumes as consume>"${consume}"<#sep>, } ) default ResponseEntity<@compress single_line=true><#if operation.responseObjects[0].contentObjects[0]??> diff --git a/multiapi-engine/src/main/resources/templates/openapi/templateCallRestClient.ftlh b/multiapi-engine/src/main/resources/templates/openapi/templateCallRestClient.ftlh index 7c3d34ca..7fa10f5d 100644 --- a/multiapi-engine/src/main/resources/templates/openapi/templateCallRestClient.ftlh +++ b/multiapi-engine/src/main/resources/templates/openapi/templateCallRestClient.ftlh @@ -314,7 +314,7 @@ public class ${className?cap_first}Api { localVarReturnType = new ParameterizedTypeReference() {}; - return apiRestClient.invokeAPI(basePath,"${path.pathName}", HttpMethod.${operation.operationType}, uriVariables, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + return apiRestClient.invokeAPI(basePath,"${path.pathName}", <#if operation.customMethod>HttpMethod.valueOf("${operation.operationType}")<#else>HttpMethod.${operation.operationType}, uriVariables, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); } diff --git a/multiapi-engine/src/main/resources/templates/openapi/templateCallWebClient.ftlh b/multiapi-engine/src/main/resources/templates/openapi/templateCallWebClient.ftlh index cb813d46..bf256ae2 100644 --- a/multiapi-engine/src/main/resources/templates/openapi/templateCallWebClient.ftlh +++ b/multiapi-engine/src/main/resources/templates/openapi/templateCallWebClient.ftlh @@ -291,7 +291,7 @@ public class ${className?cap_first}Api { <#else> ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; - return apiWebClient.invokeAPI(basePath,"${path.pathName}", HttpMethod.${operation.operationType}, pathParams, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + return apiWebClient.invokeAPI(basePath,"${path.pathName}", <#if operation.customMethod>HttpMethod.valueOf("${operation.operationType}")<#else>HttpMethod.${operation.operationType}, pathParams, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); } diff --git a/multiapi-engine/src/main/resources/templates/openapi/templateHttpMethodMapping.ftlh b/multiapi-engine/src/main/resources/templates/openapi/templateHttpMethodMapping.ftlh new file mode 100644 index 00000000..c7b97abb --- /dev/null +++ b/multiapi-engine/src/main/resources/templates/openapi/templateHttpMethodMapping.ftlh @@ -0,0 +1,31 @@ +<#ftl output_format="plainText"> +package <#if packageApi??>${packageApi}<#elseif package??>${package}; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Maps an operation whose HTTP method Spring's {@code RequestMethod} has no constant for, such as OpenAPI 3.2's + * {@code QUERY} or a method of its {@code additionalOperations}, as {@code @RequestMapping} maps the others. Operations + * declared with it are mapped by {@link HttpMethodMappingConfiguration}, and only by it. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface HttpMethodMapping { + + /** The HTTP method, as it is sent. */ + String method(); + + /** The paths it is mapped to. */ + String[] value(); + + /** The media types it produces, as {@code @RequestMapping#produces}. */ + String[] produces() default {}; + + /** The media types it consumes, as {@code @RequestMapping#consumes}. */ + String[] consumes() default {}; +} diff --git a/multiapi-engine/src/main/resources/templates/openapi/templateHttpMethodMappingConfiguration.ftlh b/multiapi-engine/src/main/resources/templates/openapi/templateHttpMethodMappingConfiguration.ftlh new file mode 100644 index 00000000..df459a61 --- /dev/null +++ b/multiapi-engine/src/main/resources/templates/openapi/templateHttpMethodMappingConfiguration.ftlh @@ -0,0 +1,167 @@ +<#ftl output_format="plainText"> +<#assign jakarta = javaEEPackage == "jakarta"> +package <#if packageApi??>${packageApi}<#elseif package??>${package}; + +import java.lang.reflect.Method; +import java.util.Set; +import java.util.stream.Collectors; +<#if !isReactive> +import ${javaEEPackage}.servlet.ServletException; +import ${javaEEPackage}.servlet.http.HttpServletRequest; + + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.method.HandlerMethod; +<#if isReactive> +import org.springframework.web.cors.reactive.CorsUtils; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.result.condition.RequestCondition; +import org.springframework.web.reactive.result.method.RequestMappingInfo; +import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.server.ServerWebExchange; +<#else> +import org.springframework.web.accept.ContentNegotiationManager; +import org.springframework.web.cors.CorsUtils; +import org.springframework.web.servlet.mvc.condition.RequestCondition; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + + +/** + * Maps the operations declared with {@link HttpMethodMapping}, whose HTTP method Spring's {@code RequestMethod} has no + * constant for, next to Spring <#if isReactive>WebFlux<#else>MVC's own mappings. Spring's handler mapping ignores them, + * as they carry no {@code @RequestMapping}, and this one maps nothing else, so it can be added to an application + * whatever else it configures. It is found by component scanning of this package, or can be imported with + * {@code @Import(HttpMethodMappingConfiguration.class)}. + */ +@Configuration(proxyBeanMethods = false) +public class HttpMethodMappingConfiguration { + + /** Before Spring's own mappings, which would otherwise answer 405 to the method they do not know. */ + private static final int ORDER = -1; + + @Bean +<#if isReactive> + public HttpMethodHandlerMapping httpMethodHandlerMapping(final ObjectProvider contentTypeResolver) { + final HttpMethodHandlerMapping mapping = new HttpMethodHandlerMapping(); + mapping.setOrder(ORDER); + contentTypeResolver.ifUnique(mapping::setContentTypeResolver); + return mapping; + } +<#else> + public HttpMethodHandlerMapping httpMethodHandlerMapping(final ObjectProvider contentNegotiationManager) { + final HttpMethodHandlerMapping mapping = new HttpMethodHandlerMapping(); + mapping.setOrder(ORDER); + contentNegotiationManager.ifUnique(mapping::setContentNegotiationManager); + return mapping; + } + + + /** Maps the handler methods declared with {@link HttpMethodMapping}, and only them. */ + public static class HttpMethodHandlerMapping extends RequestMappingHandlerMapping { + + @Override + protected RequestMappingInfo getMappingForMethod(final Method method, final Class handlerType) { + final HttpMethodMapping mapping = AnnotatedElementUtils.findMergedAnnotation(method, HttpMethodMapping.class); + if (mapping == null) { + return null; + } + RequestMappingInfo info = RequestMappingInfo.paths(resolveEmbeddedValuesInPatterns(mapping.value())) + .produces(mapping.produces()) + .consumes(mapping.consumes()) + .customCondition(new HttpMethodCondition(mapping.method())) + .options(builderConfiguration()) + .build(); + final RequestMapping typeMapping = AnnotatedElementUtils.findMergedAnnotation(handlerType, RequestMapping.class); + if (typeMapping != null) { + info = RequestMappingInfo.paths(resolveEmbeddedValuesInPatterns(typeMapping.path())).options(builderConfiguration()).build().combine(info); + } + return info; + } + +<#if isReactive> + private RequestMappingInfo.BuilderConfiguration builderConfiguration() { + final RequestMappingInfo.BuilderConfiguration configuration = new RequestMappingInfo.BuilderConfiguration(); + configuration.setPatternParser(getPathPatternParser()); + configuration.setContentTypeResolver(getContentTypeResolver()); + return configuration; + } + + /** + * Leaves a request of another method to the other mappings, and answers one of this method the way Spring does, + * such as 415 for a body it does not consume. + */ + @Override + protected HandlerMethod handleNoMatch(final Set infos, final ServerWebExchange exchange) throws Exception { + final Set sameMethod = infos.stream() + .filter(info -> info.getCustomCondition() instanceof HttpMethodCondition + && ((HttpMethodCondition) info.getCustomCondition()).getMatchingCondition(exchange) != null) + .collect(Collectors.toSet()); + return sameMethod.isEmpty() ? null : super.handleNoMatch(sameMethod, exchange); + } +<#else> + private RequestMappingInfo.BuilderConfiguration builderConfiguration() { + return getBuilderConfiguration(); + } + + /** + * Leaves a request of another method to the other mappings, and answers one of this method the way Spring does, + * such as 415 for a body it does not consume. + */ + @Override + protected HandlerMethod handleNoMatch(final Set infos, final String lookupPath, final HttpServletRequest request) + throws ServletException { + final Set sameMethod = infos.stream() + .filter(info -> info.getCustomCondition() instanceof HttpMethodCondition + && ((HttpMethodCondition) info.getCustomCondition()).getMatchingCondition(request) != null) + .collect(Collectors.toSet()); + return sameMethod.isEmpty() ? null : super.handleNoMatch(sameMethod, lookupPath, request); + } + + } + + /** Matches the requests of one HTTP method, and the CORS pre-flight requests asking for it. */ + static final class HttpMethodCondition implements RequestCondition { + + private final String method; + + HttpMethodCondition(final String method) { + this.method = method; + } + + @Override + public HttpMethodCondition combine(final HttpMethodCondition other) { + return other; + } + +<#if isReactive> + @Override + public HttpMethodCondition getMatchingCondition(final ServerWebExchange exchange) { + final String requested = CorsUtils.isPreFlightRequest(exchange.getRequest()) + ? exchange.getRequest().getHeaders().getFirst("Access-Control-Request-Method") + : exchange.getRequest().<#if jakarta>getMethod().name()<#else>getMethodValue(); + return method.equals(requested) ? this : null; + } + + @Override + public int compareTo(final HttpMethodCondition other, final ServerWebExchange exchange) { + return 0; + } +<#else> + @Override + public HttpMethodCondition getMatchingCondition(final HttpServletRequest request) { + final String requested = CorsUtils.isPreFlightRequest(request) ? request.getHeader("Access-Control-Request-Method") : request.getMethod(); + return method.equals(requested) ? this : null; + } + + @Override + public int compareTo(final HttpMethodCondition other, final HttpServletRequest request) { + return 0; + } + + } +} diff --git a/multiapi-engine/src/main/resources/templates/openapi/templateReactive.ftlh b/multiapi-engine/src/main/resources/templates/openapi/templateReactive.ftlh index e11e7ea6..b226d8d9 100644 --- a/multiapi-engine/src/main/resources/templates/openapi/templateReactive.ftlh +++ b/multiapi-engine/src/main/resources/templates/openapi/templateReactive.ftlh @@ -130,12 +130,14 @@ public interface ${className?cap_first}Api { } ) - @RequestMapping( - method = RequestMethod.${operation.operationType}, + <#if operation.customMethod>@HttpMethodMapping( + method = "${operation.operationType}",<#else>@RequestMapping( + method = RequestMethod.${operation.operationType}, value = "${path.pathName}", produces = {<#if operation.streamingResponse><#if operation.streamingAlsoJson>"application/json", "${operation.streamingMediaType}"<#else>"application/json"}<#if operation.requestObjects?has_content && operation.requestObjects[0].isFormData>, consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}<#elseif operation.requestObjects?has_content && operation.requestObjects[0].streaming>, - consumes = {"${operation.consumes[0]}"} + consumes = {"${operation.consumes[0]}"}<#elseif operation.customMethod && operation.requestObjects?has_content && operation.consumes?has_content>, + consumes = {<#list operation.consumes as consume>"${consume}"<#sep>, } ) <#if operation.securities?has_content> default Mono>><#else><@compress single_line=true><<#if operation.responseObjects[0].contentObjects[0]??><#if operation.responseObjects[0].contentObjects[0].dataType.baseType == "array" || operation.responseObjects[0].contentObjects[0].dataType.baseType == "map">Flux<${operation.responseObjects[0].contentObjects[0].dataType.innerType}><#else>${operation.responseObjects[0].contentObjects[0].dataType}><#else>Void> ${operation.operationId}(<@compress single_line=true> diff --git a/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorFixtures.java b/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorFixtures.java index bb737ab8..c7f5af7e 100644 --- a/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorFixtures.java +++ b/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorFixtures.java @@ -373,6 +373,62 @@ public final class OpenApiGeneratorFixtures { .modelPackage("com.sngular.multifileplugin.ndjsonstreamjson.model") .modelNameSuffix("DTO").useTagsGroup(true).build()); + static final List TEST_OPENAPI_32 = List + .of(SpecFile.builder().filePath("openapigenerator/testOpenApi32/api-test.yml") + .apiPackage("com.sngular.multifileplugin.openapi32") + .modelPackage("com.sngular.multifileplugin.openapi32.model") + .clientPackage("com.sngular.multifileplugin.openapi32.client") + .modelNameSuffix("DTO").useTagsGroup(true).build()); + + static final List TEST_OPENAPI_32_REACTIVE = List + .of(SpecFile.builder().filePath("openapigenerator/testOpenApi32/api-test.yml") + .apiPackage("com.sngular.multifileplugin.openapi32reactive") + .modelPackage("com.sngular.multifileplugin.openapi32reactive.model") + .clientPackage("com.sngular.multifileplugin.openapi32reactive.client") + .modelNameSuffix("DTO").useTagsGroup(true).isReactive(true).build()); + + static final List TEST_OPENAPI_32_REST_CLIENT = List + .of(SpecFile.builder().filePath("openapigenerator/testOpenApi32/api-test.yml") + .apiPackage("com.sngular.multifileplugin.openapi32restclient") + .modelPackage("com.sngular.multifileplugin.openapi32restclient.model") + .clientPackage("com.sngular.multifileplugin.openapi32restclient.client") + .modelNameSuffix("DTO").useTagsGroup(true).callMode(true).build()); + + static final List TEST_OPENAPI_32_WEB_CLIENT = List + .of(SpecFile.builder().filePath("openapigenerator/testOpenApi32/api-test.yml") + .apiPackage("com.sngular.multifileplugin.openapi32webclient") + .modelPackage("com.sngular.multifileplugin.openapi32webclient.model") + .clientPackage("com.sngular.multifileplugin.openapi32webclient.client") + .modelNameSuffix("DTO").useTagsGroup(true).callMode(true).isReactive(true).build()); + + static final List TEST_OPENAPI_32_HTTP_EXCHANGE = List + .of(SpecFile.builder().filePath("openapigenerator/testOpenApi32/api-test.yml") + .apiPackage("com.sngular.multifileplugin.openapi32httpexchange") + .modelPackage("com.sngular.multifileplugin.openapi32httpexchange.model") + .clientPackage("com.sngular.multifileplugin.openapi32httpexchange.client") + .modelNameSuffix("DTO").useTagsGroup(true).callMode(true).useHttpExchange(true).build()); + + static final List TEST_OPENAPI_32_JSON_SEQ = List + .of(SpecFile.builder().filePath("openapigenerator/testOpenApi32/api-json-seq.yml") + .apiPackage("com.sngular.multifileplugin.openapi32jsonseq") + .modelPackage("com.sngular.multifileplugin.openapi32jsonseq.model") + .clientPackage("com.sngular.multifileplugin.openapi32jsonseq.client") + .modelNameSuffix("DTO").useTagsGroup(true).build()); + + static final List TEST_OPENAPI_32_SSE = List + .of(SpecFile.builder().filePath("openapigenerator/testOpenApi32/api-sse.yml") + .apiPackage("com.sngular.multifileplugin.openapi32sse") + .modelPackage("com.sngular.multifileplugin.openapi32sse.model") + .clientPackage("com.sngular.multifileplugin.openapi32sse.client") + .modelNameSuffix("DTO").useTagsGroup(true).build()); + + static final List TEST_OPENAPI_32_QUERYSTRING_JSON = List + .of(SpecFile.builder().filePath("openapigenerator/testOpenApi32/api-querystring-json.yml") + .apiPackage("com.sngular.multifileplugin.openapi32querystringjson") + .modelPackage("com.sngular.multifileplugin.openapi32querystringjson.model") + .clientPackage("com.sngular.multifileplugin.openapi32querystringjson.client") + .modelNameSuffix("DTO").useTagsGroup(true).build()); + static final List TEST_HTTP_EXCHANGE_CLIENT = List .of(SpecFile.builder().filePath("openapigenerator/testHttpExchangeClient/api-test.yml") .apiPackage("com.sngular.multifileplugin.httpexchange") @@ -2163,6 +2219,17 @@ static Function validateNdjsonStreaming(final String packageFolde Collections.emptyList(), null); } + static Function validateOpenApi32(final String packageFolder, final String assetsFolder, final List apiFiles, + final List modelFiles) { + final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/" + packageFolder; + + final String ASSETS_PATH = "openapigenerator/testOpenApi32/assets/" + assetsFolder + "/"; + + return path -> commonTest(path, apiFiles.stream().map(file -> ASSETS_PATH + file).toList(), + modelFiles.stream().map(file -> ASSETS_PATH + file).toList(), DEFAULT_TARGET_API, DEFAULT_TARGET_API + "/model", + Collections.emptyList(), null); + } + static Function validateHttpExchangeClient(final String packageFolder, final String assetsFolder) { final String DEFAULT_TARGET_API = "generated/com/sngular/multifileplugin/" + packageFolder; diff --git a/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorJakartaTest.java b/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorJakartaTest.java index 1a8c6a9f..b314fa65 100644 --- a/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorJakartaTest.java +++ b/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorJakartaTest.java @@ -42,6 +42,8 @@ static Stream fileSpecToProcess() { Arguments.of("testNdjsonStreaming_imperative", OpenApiGeneratorFixtures.TEST_NDJSON_STREAMING, OpenApiGeneratorFixtures.validateNdjsonStreaming("ndjsonstreaming", "imperative-boot3", List.of("EventsApi.java", "ItemsApi.java", "NdjsonSupport.java"), List.of())), + Arguments.of("testOpenApi32_reactive", OpenApiGeneratorFixtures.TEST_OPENAPI_32_REACTIVE, + OpenApiGeneratorFixtures.validateOpenApi32("openapi32reactive", "reactive-boot3", List.of("HttpMethodMapping.java", "HttpMethodMappingConfiguration.java", "ItemsApi.java"), List.of())), Arguments.of("testValidationAnnotations", OpenApiGeneratorFixtures.TEST_VALIDATION_ANNOTATIONS, OpenApiGeneratorFixtures.validateValidationAnnotations(SPRING_BOOT_VERSION)), Arguments.of("testValidationAnnotationsLombok", OpenApiGeneratorFixtures.TEST_VALIDATION_ANNOTATIONS_LOMBOK, diff --git a/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorSpringBoot4Test.java b/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorSpringBoot4Test.java index e8862798..ab7bde28 100644 --- a/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorSpringBoot4Test.java +++ b/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorSpringBoot4Test.java @@ -62,6 +62,14 @@ static Stream fileSpecToProcess() { Arguments.of("testNdjsonStreaming_httpexchange", OpenApiGeneratorFixtures.TEST_NDJSON_STREAMING_HTTP_EXCHANGE, OpenApiGeneratorFixtures.validateNdjsonStreaming("ndjsonstreaminghttpexchange", "httpexchange", List.of("EventsApi.java", "ItemsApi.java"), List.of())), + Arguments.of("testOpenApi32_imperative", OpenApiGeneratorFixtures.TEST_OPENAPI_32, + OpenApiGeneratorFixtures.validateOpenApi32("openapi32", "imperative-boot4", List.of("HttpMethodMapping.java", "HttpMethodMappingConfiguration.java", "ItemsApi.java"), List.of())), + Arguments.of("testOpenApi32_restclient", OpenApiGeneratorFixtures.TEST_OPENAPI_32_REST_CLIENT, + OpenApiGeneratorFixtures.validateOpenApi32("openapi32restclient", "restclient", List.of("ItemsApi.java"), List.of())), + Arguments.of("testOpenApi32_webclient", OpenApiGeneratorFixtures.TEST_OPENAPI_32_WEB_CLIENT, + OpenApiGeneratorFixtures.validateOpenApi32("openapi32webclient", "webclient", List.of("ItemsApi.java"), List.of())), + Arguments.of("testOpenApi32_httpexchange", OpenApiGeneratorFixtures.TEST_OPENAPI_32_HTTP_EXCHANGE, + OpenApiGeneratorFixtures.validateOpenApi32("openapi32httpexchange", "httpexchange", List.of("ItemsApi.java"), List.of())), Arguments.of("testQueryObjectsHttpExchange", OpenApiGeneratorFixtures.TEST_QUERY_OBJECTS_HTTP_EXCHANGE, OpenApiGeneratorFixtures.validateQueryObjectsHttpExchange()) ); diff --git a/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorTest.java b/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorTest.java index 51a6f114..61bf7318 100644 --- a/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorTest.java +++ b/multiapi-engine/src/test/java/com/sngular/api/generator/plugin/openapi/OpenApiGeneratorTest.java @@ -143,6 +143,10 @@ static Stream fileSpecToProcess() { Arguments.of("testNdjsonStreaming_webclient", OpenApiGeneratorFixtures.TEST_NDJSON_STREAMING_WEB_CLIENT, OpenApiGeneratorFixtures.validateNdjsonStreaming("ndjsonstreamingwebclient", "webclient", List.of("EventsApi.java", "ItemsApi.java"), List.of())), + Arguments.of("testOpenApi32_imperative", OpenApiGeneratorFixtures.TEST_OPENAPI_32, + OpenApiGeneratorFixtures.validateOpenApi32("openapi32", "imperative", List.of("HttpMethodMapping.java", "HttpMethodMappingConfiguration.java", "ItemsApi.java"), List.of("CriteriaDTO.java", "FilterDTO.java", "ItemDTO.java"))), + Arguments.of("testOpenApi32_reactive", OpenApiGeneratorFixtures.TEST_OPENAPI_32_REACTIVE, + OpenApiGeneratorFixtures.validateOpenApi32("openapi32reactive", "reactive", List.of("HttpMethodMapping.java", "HttpMethodMappingConfiguration.java", "ItemsApi.java"), List.of())), Arguments.of("testRequestBodyRequired_imperative", OpenApiGeneratorFixtures.TEST_REQUEST_BODY_REQUIRED, OpenApiGeneratorFixtures.validateRequestBodyRequired("requestbodyrequired", "imperative")), Arguments.of("testRequestBodyRequired_reactive", OpenApiGeneratorFixtures.TEST_REQUEST_BODY_REQUIRED_REACTIVE, @@ -258,6 +262,37 @@ void testExceptionForTestGenerationWithNoOperationId() { .isInstanceOf(InvalidAPIException.class); } + @Test + void testCustomHttpMethodsNeedSpringBoot3Clients() { + // Spring Framework 5's HttpMethod is an enum, so its clients cannot send QUERY or PURGE. + Assertions.assertThatThrownBy(() -> openApiGenerator.processFileSpec(OpenApiGeneratorFixtures.TEST_OPENAPI_32_REST_CLIENT)) + .isInstanceOf(CodeGenerationException.class) + .hasMessageContaining("queryItems") + .hasMessageContaining("springBootVersion 3"); + } + + @Test + void testJsonSeqIsNotGeneratedYet() { + Assertions.assertThatThrownBy(() -> openApiGenerator.processFileSpec(OpenApiGeneratorFixtures.TEST_OPENAPI_32_JSON_SEQ)) + .isInstanceOf(CodeGenerationException.class) + .hasMessageContaining("application/json-seq"); + } + + @Test + void testServerSentEventsAreNotGeneratedYet() { + Assertions.assertThatThrownBy(() -> openApiGenerator.processFileSpec(OpenApiGeneratorFixtures.TEST_OPENAPI_32_SSE)) + .isInstanceOf(CodeGenerationException.class) + .hasMessageContaining("text/event-stream"); + } + + @Test + void testQuerystringMustBeFormEncoded() { + Assertions.assertThatThrownBy(() -> openApiGenerator.processFileSpec(OpenApiGeneratorFixtures.TEST_OPENAPI_32_QUERYSTRING_JSON)) + .isInstanceOf(CodeGenerationException.class) + .hasMessageContaining("criteria") + .hasMessageContaining("application/x-www-form-urlencoded"); + } + @Test void testNdjsonStreamingNeedsReactiveClient() { // RestTemplate and RestClient read a response whole, so they cannot hand the items over as they are streamed. diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/api-json-seq.yml b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/api-json-seq.yml new file mode 100644 index 00000000..49b253cb --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/api-json-seq.yml @@ -0,0 +1,19 @@ +openapi: "3.2.0" +info: + title: Not generated yet + version: 1.0.0 +paths: + /items: + get: + tags: [items] + operationId: streamItems + responses: + '200': + description: The items + content: + application/json-seq: + itemSchema: + type: object + properties: + id: + type: string diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/api-querystring-json.yml b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/api-querystring-json.yml new file mode 100644 index 00000000..4e0368b9 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/api-querystring-json.yml @@ -0,0 +1,22 @@ +openapi: "3.2.0" +info: + title: A query string that is not form encoded + version: 1.0.0 +paths: + /items: + get: + tags: [items] + operationId: searchItems + parameters: + - name: criteria + in: querystring + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + '204': + description: Searched diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/api-sse.yml b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/api-sse.yml new file mode 100644 index 00000000..1c454689 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/api-sse.yml @@ -0,0 +1,19 @@ +openapi: "3.2.0" +info: + title: Not generated yet + version: 1.0.0 +paths: + /items: + get: + tags: [items] + operationId: streamItems + responses: + '200': + description: The items + content: + text/event-stream: + itemSchema: + type: object + properties: + id: + type: string diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/api-test.yml b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/api-test.yml new file mode 100644 index 00000000..40c44ca3 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/api-test.yml @@ -0,0 +1,108 @@ +openapi: "3.2.0" +info: + title: OpenAPI 3.2 operations + version: 1.0.0 +paths: + /items: + query: + tags: [items] + operationId: queryItems + summary: Finds the items that match a filter + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Filter" + responses: + '200': + description: The matching items + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Item" + options: + tags: [items] + operationId: itemsOptions + responses: + '204': + summary: The methods allowed + /items/search: + get: + tags: [items] + operationId: searchItems + parameters: + - name: criteria + in: querystring + content: + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/Criteria" + responses: + '200': + description: The items found + content: + application/json: + $ref: "#/components/mediaTypes/ItemList" + /items/{itemId}: + parameters: + - name: itemId + in: path + required: true + schema: + type: string + get: + tags: [items] + operationId: getItem + responses: + '200': + description: The item + content: + application/json: + schema: + $ref: "#/components/schemas/Item" + '404': + summary: No such item + head: + tags: [items] + operationId: checkItem + responses: + '200': + summary: The item exists + additionalOperations: + PURGE: + tags: [items] + operationId: purgeItem + responses: + '204': + summary: Purged from the caches +components: + mediaTypes: + ItemList: + schema: + type: array + items: + $ref: "#/components/schemas/Item" + schemas: + Item: + type: object + properties: + id: + type: string + name: + type: string + Filter: + type: object + properties: + name: + type: string + Criteria: + type: object + properties: + name: + type: string + page: + type: integer + format: int32 diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/httpexchange/ItemsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/httpexchange/ItemsApi.java new file mode 100644 index 00000000..abffa6c9 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/httpexchange/ItemsApi.java @@ -0,0 +1,81 @@ +package com.sngular.multifileplugin.openapi32httpexchange; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.service.annotation.DeleteExchange; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.annotation.PatchExchange; +import org.springframework.web.service.annotation.PostExchange; +import org.springframework.web.service.annotation.PutExchange; + +import com.sngular.multifileplugin.openapi32httpexchange.model.FilterDTO; +import com.sngular.multifileplugin.openapi32httpexchange.model.ItemDTO; + +/** + * Items API, as a Spring HTTP service interface. Back it with a configured {@code RestClient}, whose + * base URL, authentication, timeouts and message converters apply to every request: + *
+ * HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient)).build()
+ *     .createClient(ItemsApi.class);
+ * 
+ * or, on Spring Boot 4, register it with {@code @ImportHttpServices}. + */ +@HttpExchange +public interface ItemsApi { + + /** + * QUERY /items: Finds the items that match a filter + * @param filterDTO (required) + * @return The matching items (status code 200); + */ + @HttpExchange(method = "QUERY", url = "/items", accept = {"application/json"}, contentType = "application/json") + ResponseEntity> queryItems(@RequestBody(required = true) FilterDTO filterDTO); + + /** + * OPTIONS /items + * @return The methods allowed (status code 204); + */ + @HttpExchange(method = "OPTIONS", url = "/items") + ResponseEntity itemsOptions(); + + /** + * GET /items/search + * @param name + * @param page + * @return The items found (status code 200); + */ + @GetExchange(url = "/items/search", accept = {"application/json"}) + ResponseEntity> searchItems(@RequestParam(name = "name", required = false) String name, @RequestParam(name = "page", required = false) Integer page); + + /** + * GET /items/{itemId} + * @param itemId (required) + * @return The item (status code 200); No such item (status code 404); + */ + @GetExchange(url = "/items/{itemId}", accept = {"application/json"}) + ResponseEntity getItem(@PathVariable("itemId") String itemId); + + /** + * HEAD /items/{itemId} + * @param itemId (required) + * @return The item exists (status code 200); + */ + @HttpExchange(method = "HEAD", url = "/items/{itemId}") + ResponseEntity checkItem(@PathVariable("itemId") String itemId); + + /** + * PURGE /items/{itemId} + * @param itemId (required) + * @return Purged from the caches (status code 204); + */ + @HttpExchange(method = "PURGE", url = "/items/{itemId}") + ResponseEntity purgeItem(@PathVariable("itemId") String itemId); +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative-boot4/HttpMethodMapping.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative-boot4/HttpMethodMapping.java new file mode 100644 index 00000000..d56bcf20 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative-boot4/HttpMethodMapping.java @@ -0,0 +1,30 @@ +package com.sngular.multifileplugin.openapi32; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Maps an operation whose HTTP method Spring's {@code RequestMethod} has no constant for, such as OpenAPI 3.2's + * {@code QUERY} or a method of its {@code additionalOperations}, as {@code @RequestMapping} maps the others. Operations + * declared with it are mapped by {@link HttpMethodMappingConfiguration}, and only by it. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface HttpMethodMapping { + + /** The HTTP method, as it is sent. */ + String method(); + + /** The paths it is mapped to. */ + String[] value(); + + /** The media types it produces, as {@code @RequestMapping#produces}. */ + String[] produces() default {}; + + /** The media types it consumes, as {@code @RequestMapping#consumes}. */ + String[] consumes() default {}; +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative-boot4/HttpMethodMappingConfiguration.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative-boot4/HttpMethodMappingConfiguration.java new file mode 100644 index 00000000..5c81b704 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative-boot4/HttpMethodMappingConfiguration.java @@ -0,0 +1,108 @@ +package com.sngular.multifileplugin.openapi32; + +import java.lang.reflect.Method; +import java.util.Set; +import java.util.stream.Collectors; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.accept.ContentNegotiationManager; +import org.springframework.web.cors.CorsUtils; +import org.springframework.web.servlet.mvc.condition.RequestCondition; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +/** + * Maps the operations declared with {@link HttpMethodMapping}, whose HTTP method Spring's {@code RequestMethod} has no + * constant for, next to Spring MVC's own mappings. Spring's handler mapping ignores them, + * as they carry no {@code @RequestMapping}, and this one maps nothing else, so it can be added to an application + * whatever else it configures. It is found by component scanning of this package, or can be imported with + * {@code @Import(HttpMethodMappingConfiguration.class)}. + */ +@Configuration(proxyBeanMethods = false) +public class HttpMethodMappingConfiguration { + + /** Before Spring's own mappings, which would otherwise answer 405 to the method they do not know. */ + private static final int ORDER = -1; + + @Bean + public HttpMethodHandlerMapping httpMethodHandlerMapping(final ObjectProvider contentNegotiationManager) { + final HttpMethodHandlerMapping mapping = new HttpMethodHandlerMapping(); + mapping.setOrder(ORDER); + contentNegotiationManager.ifUnique(mapping::setContentNegotiationManager); + return mapping; + } + + /** Maps the handler methods declared with {@link HttpMethodMapping}, and only them. */ + public static class HttpMethodHandlerMapping extends RequestMappingHandlerMapping { + + @Override + protected RequestMappingInfo getMappingForMethod(final Method method, final Class handlerType) { + final HttpMethodMapping mapping = AnnotatedElementUtils.findMergedAnnotation(method, HttpMethodMapping.class); + if (mapping == null) { + return null; + } + RequestMappingInfo info = RequestMappingInfo.paths(resolveEmbeddedValuesInPatterns(mapping.value())) + .produces(mapping.produces()) + .consumes(mapping.consumes()) + .customCondition(new HttpMethodCondition(mapping.method())) + .options(builderConfiguration()) + .build(); + final RequestMapping typeMapping = AnnotatedElementUtils.findMergedAnnotation(handlerType, RequestMapping.class); + if (typeMapping != null) { + info = RequestMappingInfo.paths(resolveEmbeddedValuesInPatterns(typeMapping.path())).options(builderConfiguration()).build().combine(info); + } + return info; + } + + private RequestMappingInfo.BuilderConfiguration builderConfiguration() { + return getBuilderConfiguration(); + } + + /** + * Leaves a request of another method to the other mappings, and answers one of this method the way Spring does, + * such as 415 for a body it does not consume. + */ + @Override + protected HandlerMethod handleNoMatch(final Set infos, final String lookupPath, final HttpServletRequest request) + throws ServletException { + final Set sameMethod = infos.stream() + .filter(info -> info.getCustomCondition() instanceof HttpMethodCondition + && ((HttpMethodCondition) info.getCustomCondition()).getMatchingCondition(request) != null) + .collect(Collectors.toSet()); + return sameMethod.isEmpty() ? null : super.handleNoMatch(sameMethod, lookupPath, request); + } + } + + /** Matches the requests of one HTTP method, and the CORS pre-flight requests asking for it. */ + static final class HttpMethodCondition implements RequestCondition { + + private final String method; + + HttpMethodCondition(final String method) { + this.method = method; + } + + @Override + public HttpMethodCondition combine(final HttpMethodCondition other) { + return other; + } + + @Override + public HttpMethodCondition getMatchingCondition(final HttpServletRequest request) { + final String requested = CorsUtils.isPreFlightRequest(request) ? request.getHeader("Access-Control-Request-Method") : request.getMethod(); + return method.equals(requested) ? this : null; + } + + @Override + public int compareTo(final HttpMethodCondition other, final HttpServletRequest request) { + return 0; + } + } +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative-boot4/ItemsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative-boot4/ItemsApi.java new file mode 100644 index 00000000..88311244 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative-boot4/ItemsApi.java @@ -0,0 +1,159 @@ +package com.sngular.multifileplugin.openapi32; + +import java.util.Optional; +import java.util.List; +import java.util.Map; +import jakarta.validation.Valid; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.NativeWebRequest; + +import com.sngular.multifileplugin.openapi32.model.FilterDTO; +import com.sngular.multifileplugin.openapi32.model.ItemDTO; + +public interface ItemsApi { + + /** + * QUERY /items: Finds the items that match a filter + * @param filterDTO (required) + * @return The matching items; (status code 200) + */ + + @Operation( + operationId = "queryItems", + summary = "Finds the items that match a filter", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The matching items", content = @Content(mediaType = "application/json", schema = @Schema(implementation = List.class))) + } + ) + @HttpMethodMapping( + method = "QUERY", + value = "/items", + produces = {"application/json"}, + consumes = {"application/json"} + ) + + default ResponseEntity> queryItems(@Parameter(name = "filterDTO", description = "", required = true, schema = @Schema(description = "")) @Valid @RequestBody FilterDTO filterDTO) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + /** + * OPTIONS /items + * @return The methods allowed; (status code 204) + */ + + @Operation( + operationId = "itemsOptions", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "204", description = "The methods allowed") + } + ) + @RequestMapping( + method = RequestMethod.OPTIONS, + value = "/items", + produces = {"application/json"} + ) + + default ResponseEntity itemsOptions() { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + /** + * GET /items/search + * @param name false @param page false + * @return The items found; (status code 200) + */ + + @Operation( + operationId = "searchItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The items found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = List.class))) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items/search", + produces = {"application/json"} + ) + + default ResponseEntity> searchItems(@Parameter(name = "name", required = false, schema = @Schema(description = "")) @RequestParam(name = "name", required = false) String name , @Parameter(name = "page", required = false, schema = @Schema(description = "")) @RequestParam(name = "page", required = false) Integer page) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + /** + * GET /items/{itemId} + * @param itemId true + * @return The item; (status code 200) No such item; (status code 404) + */ + + @Operation( + operationId = "getItem", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The item", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ItemDTO.class))), + @ApiResponse(responseCode = "404", description = "No such item") + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items/{itemId}", + produces = {"application/json"} + ) + + default ResponseEntity getItem(@Parameter(name = "itemId", required = true, schema = @Schema(description = "")) @PathVariable("itemId") String itemId) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + /** + * HEAD /items/{itemId} + * @param itemId true + * @return The item exists; (status code 200) + */ + + @Operation( + operationId = "checkItem", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The item exists") + } + ) + @RequestMapping( + method = RequestMethod.HEAD, + value = "/items/{itemId}", + produces = {"application/json"} + ) + + default ResponseEntity checkItem(@Parameter(name = "itemId", required = true, schema = @Schema(description = "")) @PathVariable("itemId") String itemId) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + /** + * PURGE /items/{itemId} + * @param itemId true + * @return Purged from the caches; (status code 204) + */ + + @Operation( + operationId = "purgeItem", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "204", description = "Purged from the caches") + } + ) + @HttpMethodMapping( + method = "PURGE", + value = "/items/{itemId}", + produces = {"application/json"} + ) + + default ResponseEntity purgeItem(@Parameter(name = "itemId", required = true, schema = @Schema(description = "")) @PathVariable("itemId") String itemId) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/CriteriaDTO.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/CriteriaDTO.java new file mode 100644 index 00000000..9db85c11 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/CriteriaDTO.java @@ -0,0 +1,94 @@ +package com.sngular.multifileplugin.openapi32.model; + +import java.util.Objects; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonDeserialize(builder = CriteriaDTO.CriteriaDTOBuilder.class) +public class CriteriaDTO { + + @JsonProperty(value ="name") + private String name; + @JsonProperty(value ="page") + private Integer page; + + private CriteriaDTO(CriteriaDTOBuilder builder) { + this.name = builder.name; + this.page = builder.page; + + } + + public static CriteriaDTO.CriteriaDTOBuilder builder() { + return new CriteriaDTO.CriteriaDTOBuilder(); + } + + @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "") + public static class CriteriaDTOBuilder { + + private String name; + private Integer page; + + public CriteriaDTO.CriteriaDTOBuilder name(String name) { + this.name = name; + return this; + } + + public CriteriaDTO.CriteriaDTOBuilder page(Integer page) { + this.page = page; + return this; + } + + public CriteriaDTO build() { + CriteriaDTO criteriaDTO = new CriteriaDTO(this); + return criteriaDTO; + } + } + + @Schema(name = "name", required = false) + public String getName() { + return name; + } + public void setName(String name) { + this.name = name; + } + + @Schema(name = "page", required = false) + public Integer getPage() { + return page; + } + public void setPage(Integer page) { + this.page = page; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CriteriaDTO criteriaDTO = (CriteriaDTO) o; + return Objects.equals(this.name, criteriaDTO.name) && Objects.equals(this.page, criteriaDTO.page); + } + + @Override + public int hashCode() { + return Objects.hash(name, page); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("CriteriaDTO{"); + sb.append(" name:").append(name).append(","); + sb.append(" page:").append(page); + sb.append("}"); + return sb.toString(); + } + + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/FilterDTO.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/FilterDTO.java new file mode 100644 index 00000000..8d30bee9 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/FilterDTO.java @@ -0,0 +1,76 @@ +package com.sngular.multifileplugin.openapi32.model; + +import java.util.Objects; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonDeserialize(builder = FilterDTO.FilterDTOBuilder.class) +public class FilterDTO { + + @JsonProperty(value ="name") + private String name; + + private FilterDTO(FilterDTOBuilder builder) { + this.name = builder.name; + + } + + public static FilterDTO.FilterDTOBuilder builder() { + return new FilterDTO.FilterDTOBuilder(); + } + + @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "") + public static class FilterDTOBuilder { + + private String name; + + public FilterDTO.FilterDTOBuilder name(String name) { + this.name = name; + return this; + } + + public FilterDTO build() { + FilterDTO filterDTO = new FilterDTO(this); + return filterDTO; + } + } + + @Schema(name = "name", required = false) + public String getName() { + return name; + } + public void setName(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FilterDTO filterDTO = (FilterDTO) o; + return Objects.equals(this.name, filterDTO.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("FilterDTO{"); + sb.append(" name:").append(name); + sb.append("}"); + return sb.toString(); + } + + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/HttpMethodMapping.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/HttpMethodMapping.java new file mode 100644 index 00000000..d56bcf20 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/HttpMethodMapping.java @@ -0,0 +1,30 @@ +package com.sngular.multifileplugin.openapi32; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Maps an operation whose HTTP method Spring's {@code RequestMethod} has no constant for, such as OpenAPI 3.2's + * {@code QUERY} or a method of its {@code additionalOperations}, as {@code @RequestMapping} maps the others. Operations + * declared with it are mapped by {@link HttpMethodMappingConfiguration}, and only by it. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface HttpMethodMapping { + + /** The HTTP method, as it is sent. */ + String method(); + + /** The paths it is mapped to. */ + String[] value(); + + /** The media types it produces, as {@code @RequestMapping#produces}. */ + String[] produces() default {}; + + /** The media types it consumes, as {@code @RequestMapping#consumes}. */ + String[] consumes() default {}; +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/HttpMethodMappingConfiguration.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/HttpMethodMappingConfiguration.java new file mode 100644 index 00000000..45dc7d3a --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/HttpMethodMappingConfiguration.java @@ -0,0 +1,108 @@ +package com.sngular.multifileplugin.openapi32; + +import java.lang.reflect.Method; +import java.util.Set; +import java.util.stream.Collectors; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.accept.ContentNegotiationManager; +import org.springframework.web.cors.CorsUtils; +import org.springframework.web.servlet.mvc.condition.RequestCondition; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +/** + * Maps the operations declared with {@link HttpMethodMapping}, whose HTTP method Spring's {@code RequestMethod} has no + * constant for, next to Spring MVC's own mappings. Spring's handler mapping ignores them, + * as they carry no {@code @RequestMapping}, and this one maps nothing else, so it can be added to an application + * whatever else it configures. It is found by component scanning of this package, or can be imported with + * {@code @Import(HttpMethodMappingConfiguration.class)}. + */ +@Configuration(proxyBeanMethods = false) +public class HttpMethodMappingConfiguration { + + /** Before Spring's own mappings, which would otherwise answer 405 to the method they do not know. */ + private static final int ORDER = -1; + + @Bean + public HttpMethodHandlerMapping httpMethodHandlerMapping(final ObjectProvider contentNegotiationManager) { + final HttpMethodHandlerMapping mapping = new HttpMethodHandlerMapping(); + mapping.setOrder(ORDER); + contentNegotiationManager.ifUnique(mapping::setContentNegotiationManager); + return mapping; + } + + /** Maps the handler methods declared with {@link HttpMethodMapping}, and only them. */ + public static class HttpMethodHandlerMapping extends RequestMappingHandlerMapping { + + @Override + protected RequestMappingInfo getMappingForMethod(final Method method, final Class handlerType) { + final HttpMethodMapping mapping = AnnotatedElementUtils.findMergedAnnotation(method, HttpMethodMapping.class); + if (mapping == null) { + return null; + } + RequestMappingInfo info = RequestMappingInfo.paths(resolveEmbeddedValuesInPatterns(mapping.value())) + .produces(mapping.produces()) + .consumes(mapping.consumes()) + .customCondition(new HttpMethodCondition(mapping.method())) + .options(builderConfiguration()) + .build(); + final RequestMapping typeMapping = AnnotatedElementUtils.findMergedAnnotation(handlerType, RequestMapping.class); + if (typeMapping != null) { + info = RequestMappingInfo.paths(resolveEmbeddedValuesInPatterns(typeMapping.path())).options(builderConfiguration()).build().combine(info); + } + return info; + } + + private RequestMappingInfo.BuilderConfiguration builderConfiguration() { + return getBuilderConfiguration(); + } + + /** + * Leaves a request of another method to the other mappings, and answers one of this method the way Spring does, + * such as 415 for a body it does not consume. + */ + @Override + protected HandlerMethod handleNoMatch(final Set infos, final String lookupPath, final HttpServletRequest request) + throws ServletException { + final Set sameMethod = infos.stream() + .filter(info -> info.getCustomCondition() instanceof HttpMethodCondition + && ((HttpMethodCondition) info.getCustomCondition()).getMatchingCondition(request) != null) + .collect(Collectors.toSet()); + return sameMethod.isEmpty() ? null : super.handleNoMatch(sameMethod, lookupPath, request); + } + } + + /** Matches the requests of one HTTP method, and the CORS pre-flight requests asking for it. */ + static final class HttpMethodCondition implements RequestCondition { + + private final String method; + + HttpMethodCondition(final String method) { + this.method = method; + } + + @Override + public HttpMethodCondition combine(final HttpMethodCondition other) { + return other; + } + + @Override + public HttpMethodCondition getMatchingCondition(final HttpServletRequest request) { + final String requested = CorsUtils.isPreFlightRequest(request) ? request.getHeader("Access-Control-Request-Method") : request.getMethod(); + return method.equals(requested) ? this : null; + } + + @Override + public int compareTo(final HttpMethodCondition other, final HttpServletRequest request) { + return 0; + } + } +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/ItemDTO.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/ItemDTO.java new file mode 100644 index 00000000..d70b20f4 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/ItemDTO.java @@ -0,0 +1,94 @@ +package com.sngular.multifileplugin.openapi32.model; + +import java.util.Objects; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonDeserialize(builder = ItemDTO.ItemDTOBuilder.class) +public class ItemDTO { + + @JsonProperty(value ="name") + private String name; + @JsonProperty(value ="id") + private String id; + + private ItemDTO(ItemDTOBuilder builder) { + this.name = builder.name; + this.id = builder.id; + + } + + public static ItemDTO.ItemDTOBuilder builder() { + return new ItemDTO.ItemDTOBuilder(); + } + + @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "") + public static class ItemDTOBuilder { + + private String name; + private String id; + + public ItemDTO.ItemDTOBuilder name(String name) { + this.name = name; + return this; + } + + public ItemDTO.ItemDTOBuilder id(String id) { + this.id = id; + return this; + } + + public ItemDTO build() { + ItemDTO itemDTO = new ItemDTO(this); + return itemDTO; + } + } + + @Schema(name = "name", required = false) + public String getName() { + return name; + } + public void setName(String name) { + this.name = name; + } + + @Schema(name = "id", required = false) + public String getId() { + return id; + } + public void setId(String id) { + this.id = id; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ItemDTO itemDTO = (ItemDTO) o; + return Objects.equals(this.name, itemDTO.name) && Objects.equals(this.id, itemDTO.id); + } + + @Override + public int hashCode() { + return Objects.hash(name, id); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("ItemDTO{"); + sb.append(" name:").append(name).append(","); + sb.append(" id:").append(id); + sb.append("}"); + return sb.toString(); + } + + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/ItemsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/ItemsApi.java new file mode 100644 index 00000000..578f2512 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/imperative/ItemsApi.java @@ -0,0 +1,159 @@ +package com.sngular.multifileplugin.openapi32; + +import java.util.Optional; +import java.util.List; +import java.util.Map; +import javax.validation.Valid; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.NativeWebRequest; + +import com.sngular.multifileplugin.openapi32.model.FilterDTO; +import com.sngular.multifileplugin.openapi32.model.ItemDTO; + +public interface ItemsApi { + + /** + * QUERY /items: Finds the items that match a filter + * @param filterDTO (required) + * @return The matching items; (status code 200) + */ + + @Operation( + operationId = "queryItems", + summary = "Finds the items that match a filter", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The matching items", content = @Content(mediaType = "application/json", schema = @Schema(implementation = List.class))) + } + ) + @HttpMethodMapping( + method = "QUERY", + value = "/items", + produces = {"application/json"}, + consumes = {"application/json"} + ) + + default ResponseEntity> queryItems(@Parameter(name = "filterDTO", description = "", required = true, schema = @Schema(description = "")) @Valid @RequestBody FilterDTO filterDTO) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + /** + * OPTIONS /items + * @return The methods allowed; (status code 204) + */ + + @Operation( + operationId = "itemsOptions", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "204", description = "The methods allowed") + } + ) + @RequestMapping( + method = RequestMethod.OPTIONS, + value = "/items", + produces = {"application/json"} + ) + + default ResponseEntity itemsOptions() { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + /** + * GET /items/search + * @param name false @param page false + * @return The items found; (status code 200) + */ + + @Operation( + operationId = "searchItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The items found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = List.class))) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items/search", + produces = {"application/json"} + ) + + default ResponseEntity> searchItems(@Parameter(name = "name", required = false, schema = @Schema(description = "")) @RequestParam(name = "name", required = false) String name , @Parameter(name = "page", required = false, schema = @Schema(description = "")) @RequestParam(name = "page", required = false) Integer page) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + /** + * GET /items/{itemId} + * @param itemId true + * @return The item; (status code 200) No such item; (status code 404) + */ + + @Operation( + operationId = "getItem", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The item", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ItemDTO.class))), + @ApiResponse(responseCode = "404", description = "No such item") + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items/{itemId}", + produces = {"application/json"} + ) + + default ResponseEntity getItem(@Parameter(name = "itemId", required = true, schema = @Schema(description = "")) @PathVariable("itemId") String itemId) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + /** + * HEAD /items/{itemId} + * @param itemId true + * @return The item exists; (status code 200) + */ + + @Operation( + operationId = "checkItem", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The item exists") + } + ) + @RequestMapping( + method = RequestMethod.HEAD, + value = "/items/{itemId}", + produces = {"application/json"} + ) + + default ResponseEntity checkItem(@Parameter(name = "itemId", required = true, schema = @Schema(description = "")) @PathVariable("itemId") String itemId) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + /** + * PURGE /items/{itemId} + * @param itemId true + * @return Purged from the caches; (status code 204) + */ + + @Operation( + operationId = "purgeItem", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "204", description = "Purged from the caches") + } + ) + @HttpMethodMapping( + method = "PURGE", + value = "/items/{itemId}", + produces = {"application/json"} + ) + + default ResponseEntity purgeItem(@Parameter(name = "itemId", required = true, schema = @Schema(description = "")) @PathVariable("itemId") String itemId) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive-boot3/HttpMethodMapping.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive-boot3/HttpMethodMapping.java new file mode 100644 index 00000000..e7331c8d --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive-boot3/HttpMethodMapping.java @@ -0,0 +1,30 @@ +package com.sngular.multifileplugin.openapi32reactive; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Maps an operation whose HTTP method Spring's {@code RequestMethod} has no constant for, such as OpenAPI 3.2's + * {@code QUERY} or a method of its {@code additionalOperations}, as {@code @RequestMapping} maps the others. Operations + * declared with it are mapped by {@link HttpMethodMappingConfiguration}, and only by it. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface HttpMethodMapping { + + /** The HTTP method, as it is sent. */ + String method(); + + /** The paths it is mapped to. */ + String[] value(); + + /** The media types it produces, as {@code @RequestMapping#produces}. */ + String[] produces() default {}; + + /** The media types it consumes, as {@code @RequestMapping#consumes}. */ + String[] consumes() default {}; +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive-boot3/HttpMethodMappingConfiguration.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive-boot3/HttpMethodMappingConfiguration.java new file mode 100644 index 00000000..f12d7075 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive-boot3/HttpMethodMappingConfiguration.java @@ -0,0 +1,111 @@ +package com.sngular.multifileplugin.openapi32reactive; + +import java.lang.reflect.Method; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.cors.reactive.CorsUtils; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.result.condition.RequestCondition; +import org.springframework.web.reactive.result.method.RequestMappingInfo; +import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.server.ServerWebExchange; + +/** + * Maps the operations declared with {@link HttpMethodMapping}, whose HTTP method Spring's {@code RequestMethod} has no + * constant for, next to Spring WebFlux's own mappings. Spring's handler mapping ignores them, + * as they carry no {@code @RequestMapping}, and this one maps nothing else, so it can be added to an application + * whatever else it configures. It is found by component scanning of this package, or can be imported with + * {@code @Import(HttpMethodMappingConfiguration.class)}. + */ +@Configuration(proxyBeanMethods = false) +public class HttpMethodMappingConfiguration { + + /** Before Spring's own mappings, which would otherwise answer 405 to the method they do not know. */ + private static final int ORDER = -1; + + @Bean + public HttpMethodHandlerMapping httpMethodHandlerMapping(final ObjectProvider contentTypeResolver) { + final HttpMethodHandlerMapping mapping = new HttpMethodHandlerMapping(); + mapping.setOrder(ORDER); + contentTypeResolver.ifUnique(mapping::setContentTypeResolver); + return mapping; + } + + /** Maps the handler methods declared with {@link HttpMethodMapping}, and only them. */ + public static class HttpMethodHandlerMapping extends RequestMappingHandlerMapping { + + @Override + protected RequestMappingInfo getMappingForMethod(final Method method, final Class handlerType) { + final HttpMethodMapping mapping = AnnotatedElementUtils.findMergedAnnotation(method, HttpMethodMapping.class); + if (mapping == null) { + return null; + } + RequestMappingInfo info = RequestMappingInfo.paths(resolveEmbeddedValuesInPatterns(mapping.value())) + .produces(mapping.produces()) + .consumes(mapping.consumes()) + .customCondition(new HttpMethodCondition(mapping.method())) + .options(builderConfiguration()) + .build(); + final RequestMapping typeMapping = AnnotatedElementUtils.findMergedAnnotation(handlerType, RequestMapping.class); + if (typeMapping != null) { + info = RequestMappingInfo.paths(resolveEmbeddedValuesInPatterns(typeMapping.path())).options(builderConfiguration()).build().combine(info); + } + return info; + } + + private RequestMappingInfo.BuilderConfiguration builderConfiguration() { + final RequestMappingInfo.BuilderConfiguration configuration = new RequestMappingInfo.BuilderConfiguration(); + configuration.setPatternParser(getPathPatternParser()); + configuration.setContentTypeResolver(getContentTypeResolver()); + return configuration; + } + + /** + * Leaves a request of another method to the other mappings, and answers one of this method the way Spring does, + * such as 415 for a body it does not consume. + */ + @Override + protected HandlerMethod handleNoMatch(final Set infos, final ServerWebExchange exchange) throws Exception { + final Set sameMethod = infos.stream() + .filter(info -> info.getCustomCondition() instanceof HttpMethodCondition + && ((HttpMethodCondition) info.getCustomCondition()).getMatchingCondition(exchange) != null) + .collect(Collectors.toSet()); + return sameMethod.isEmpty() ? null : super.handleNoMatch(sameMethod, exchange); + } + } + + /** Matches the requests of one HTTP method, and the CORS pre-flight requests asking for it. */ + static final class HttpMethodCondition implements RequestCondition { + + private final String method; + + HttpMethodCondition(final String method) { + this.method = method; + } + + @Override + public HttpMethodCondition combine(final HttpMethodCondition other) { + return other; + } + + @Override + public HttpMethodCondition getMatchingCondition(final ServerWebExchange exchange) { + final String requested = CorsUtils.isPreFlightRequest(exchange.getRequest()) + ? exchange.getRequest().getHeaders().getFirst("Access-Control-Request-Method") + : exchange.getRequest().getMethod().name(); + return method.equals(requested) ? this : null; + } + + @Override + public int compareTo(final HttpMethodCondition other, final ServerWebExchange exchange) { + return 0; + } + } +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive-boot3/ItemsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive-boot3/ItemsApi.java new file mode 100644 index 00000000..edc2b783 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive-boot3/ItemsApi.java @@ -0,0 +1,163 @@ +package com.sngular.multifileplugin.openapi32reactive; + +import java.util.List; +import java.util.Map; +import java.nio.charset.StandardCharsets; +import jakarta.validation.Valid; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Flux; +import springfox.documentation.annotations.ApiIgnore; + +import com.sngular.multifileplugin.openapi32reactive.model.FilterDTO; +import com.sngular.multifileplugin.openapi32reactive.model.ItemDTO; + +public interface ItemsApi { + + /** + * QUERY /items: Finds the items that match a filter + * @param filterDTO (required) + * @return The matching items; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + @Operation( + operationId = "queryItems", + summary = "Finds the items that match a filter", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The matching items", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ItemDTO.class))) + } + ) + @HttpMethodMapping( + method = "QUERY", + value = "/items", + produces = {"application/json"}, + consumes = {"application/json"} + ) + default ResponseEntity> queryItems(@Parameter(name = "filterDTO", description = "", required = true, schema = @Schema(description = "")) @Valid @RequestBody Mono filterDTO, @ApiIgnore final ServerWebExchange exchange) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * OPTIONS /items + * @return The methods allowed; (status code 204) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + @Operation( + operationId = "itemsOptions", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "204", description = "The methods allowed") + } + ) + @RequestMapping( + method = RequestMethod.OPTIONS, + value = "/items", + produces = {"application/json"} + ) + default ResponseEntity itemsOptions(@ApiIgnore final ServerWebExchange exchange) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * GET /items/search + * @param name false page false + * @return The items found; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + @Operation( + operationId = "searchItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The items found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ItemDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items/search", + produces = {"application/json"} + ) + default ResponseEntity> searchItems(@Parameter(name = "name", required = false, schema = @Schema(description = "")) @RequestParam(name = "name", required = false) String name , @Parameter(name = "page", required = false, schema = @Schema(description = "")) @RequestParam(name = "page", required = false) Integer page, @ApiIgnore final ServerWebExchange exchange) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * GET /items/{itemId} + * @param itemId true + * @return The item; (status code 200) No such item; (status code 404) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + @Operation( + operationId = "getItem", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The item", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ItemDTO.class))), + @ApiResponse(responseCode = "404", description = "No such item") + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items/{itemId}", + produces = {"application/json"} + ) + default ResponseEntity> getItem(@Parameter(name = "itemId", required = true, schema = @Schema(description = "")) @PathVariable("itemId") String itemId, @ApiIgnore final ServerWebExchange exchange) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * HEAD /items/{itemId} + * @param itemId true + * @return The item exists; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + @Operation( + operationId = "checkItem", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The item exists") + } + ) + @RequestMapping( + method = RequestMethod.HEAD, + value = "/items/{itemId}", + produces = {"application/json"} + ) + default ResponseEntity checkItem(@Parameter(name = "itemId", required = true, schema = @Schema(description = "")) @PathVariable("itemId") String itemId, @ApiIgnore final ServerWebExchange exchange) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * PURGE /items/{itemId} + * @param itemId true + * @return Purged from the caches; (status code 204) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + @Operation( + operationId = "purgeItem", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "204", description = "Purged from the caches") + } + ) + @HttpMethodMapping( + method = "PURGE", + value = "/items/{itemId}", + produces = {"application/json"} + ) + default ResponseEntity purgeItem(@Parameter(name = "itemId", required = true, schema = @Schema(description = "")) @PathVariable("itemId") String itemId, @ApiIgnore final ServerWebExchange exchange) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + +} \ No newline at end of file diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive/HttpMethodMapping.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive/HttpMethodMapping.java new file mode 100644 index 00000000..e7331c8d --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive/HttpMethodMapping.java @@ -0,0 +1,30 @@ +package com.sngular.multifileplugin.openapi32reactive; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Maps an operation whose HTTP method Spring's {@code RequestMethod} has no constant for, such as OpenAPI 3.2's + * {@code QUERY} or a method of its {@code additionalOperations}, as {@code @RequestMapping} maps the others. Operations + * declared with it are mapped by {@link HttpMethodMappingConfiguration}, and only by it. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface HttpMethodMapping { + + /** The HTTP method, as it is sent. */ + String method(); + + /** The paths it is mapped to. */ + String[] value(); + + /** The media types it produces, as {@code @RequestMapping#produces}. */ + String[] produces() default {}; + + /** The media types it consumes, as {@code @RequestMapping#consumes}. */ + String[] consumes() default {}; +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive/HttpMethodMappingConfiguration.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive/HttpMethodMappingConfiguration.java new file mode 100644 index 00000000..9ad93e6d --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive/HttpMethodMappingConfiguration.java @@ -0,0 +1,111 @@ +package com.sngular.multifileplugin.openapi32reactive; + +import java.lang.reflect.Method; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.cors.reactive.CorsUtils; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.result.condition.RequestCondition; +import org.springframework.web.reactive.result.method.RequestMappingInfo; +import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.server.ServerWebExchange; + +/** + * Maps the operations declared with {@link HttpMethodMapping}, whose HTTP method Spring's {@code RequestMethod} has no + * constant for, next to Spring WebFlux's own mappings. Spring's handler mapping ignores them, + * as they carry no {@code @RequestMapping}, and this one maps nothing else, so it can be added to an application + * whatever else it configures. It is found by component scanning of this package, or can be imported with + * {@code @Import(HttpMethodMappingConfiguration.class)}. + */ +@Configuration(proxyBeanMethods = false) +public class HttpMethodMappingConfiguration { + + /** Before Spring's own mappings, which would otherwise answer 405 to the method they do not know. */ + private static final int ORDER = -1; + + @Bean + public HttpMethodHandlerMapping httpMethodHandlerMapping(final ObjectProvider contentTypeResolver) { + final HttpMethodHandlerMapping mapping = new HttpMethodHandlerMapping(); + mapping.setOrder(ORDER); + contentTypeResolver.ifUnique(mapping::setContentTypeResolver); + return mapping; + } + + /** Maps the handler methods declared with {@link HttpMethodMapping}, and only them. */ + public static class HttpMethodHandlerMapping extends RequestMappingHandlerMapping { + + @Override + protected RequestMappingInfo getMappingForMethod(final Method method, final Class handlerType) { + final HttpMethodMapping mapping = AnnotatedElementUtils.findMergedAnnotation(method, HttpMethodMapping.class); + if (mapping == null) { + return null; + } + RequestMappingInfo info = RequestMappingInfo.paths(resolveEmbeddedValuesInPatterns(mapping.value())) + .produces(mapping.produces()) + .consumes(mapping.consumes()) + .customCondition(new HttpMethodCondition(mapping.method())) + .options(builderConfiguration()) + .build(); + final RequestMapping typeMapping = AnnotatedElementUtils.findMergedAnnotation(handlerType, RequestMapping.class); + if (typeMapping != null) { + info = RequestMappingInfo.paths(resolveEmbeddedValuesInPatterns(typeMapping.path())).options(builderConfiguration()).build().combine(info); + } + return info; + } + + private RequestMappingInfo.BuilderConfiguration builderConfiguration() { + final RequestMappingInfo.BuilderConfiguration configuration = new RequestMappingInfo.BuilderConfiguration(); + configuration.setPatternParser(getPathPatternParser()); + configuration.setContentTypeResolver(getContentTypeResolver()); + return configuration; + } + + /** + * Leaves a request of another method to the other mappings, and answers one of this method the way Spring does, + * such as 415 for a body it does not consume. + */ + @Override + protected HandlerMethod handleNoMatch(final Set infos, final ServerWebExchange exchange) throws Exception { + final Set sameMethod = infos.stream() + .filter(info -> info.getCustomCondition() instanceof HttpMethodCondition + && ((HttpMethodCondition) info.getCustomCondition()).getMatchingCondition(exchange) != null) + .collect(Collectors.toSet()); + return sameMethod.isEmpty() ? null : super.handleNoMatch(sameMethod, exchange); + } + } + + /** Matches the requests of one HTTP method, and the CORS pre-flight requests asking for it. */ + static final class HttpMethodCondition implements RequestCondition { + + private final String method; + + HttpMethodCondition(final String method) { + this.method = method; + } + + @Override + public HttpMethodCondition combine(final HttpMethodCondition other) { + return other; + } + + @Override + public HttpMethodCondition getMatchingCondition(final ServerWebExchange exchange) { + final String requested = CorsUtils.isPreFlightRequest(exchange.getRequest()) + ? exchange.getRequest().getHeaders().getFirst("Access-Control-Request-Method") + : exchange.getRequest().getMethodValue(); + return method.equals(requested) ? this : null; + } + + @Override + public int compareTo(final HttpMethodCondition other, final ServerWebExchange exchange) { + return 0; + } + } +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive/ItemsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive/ItemsApi.java new file mode 100644 index 00000000..3c8a1119 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/reactive/ItemsApi.java @@ -0,0 +1,163 @@ +package com.sngular.multifileplugin.openapi32reactive; + +import java.util.List; +import java.util.Map; +import java.nio.charset.StandardCharsets; +import javax.validation.Valid; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Flux; +import springfox.documentation.annotations.ApiIgnore; + +import com.sngular.multifileplugin.openapi32reactive.model.FilterDTO; +import com.sngular.multifileplugin.openapi32reactive.model.ItemDTO; + +public interface ItemsApi { + + /** + * QUERY /items: Finds the items that match a filter + * @param filterDTO (required) + * @return The matching items; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + @Operation( + operationId = "queryItems", + summary = "Finds the items that match a filter", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The matching items", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ItemDTO.class))) + } + ) + @HttpMethodMapping( + method = "QUERY", + value = "/items", + produces = {"application/json"}, + consumes = {"application/json"} + ) + default ResponseEntity> queryItems(@Parameter(name = "filterDTO", description = "", required = true, schema = @Schema(description = "")) @Valid @RequestBody Mono filterDTO, @ApiIgnore final ServerWebExchange exchange) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * OPTIONS /items + * @return The methods allowed; (status code 204) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + @Operation( + operationId = "itemsOptions", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "204", description = "The methods allowed") + } + ) + @RequestMapping( + method = RequestMethod.OPTIONS, + value = "/items", + produces = {"application/json"} + ) + default ResponseEntity itemsOptions(@ApiIgnore final ServerWebExchange exchange) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * GET /items/search + * @param name false page false + * @return The items found; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + @Operation( + operationId = "searchItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The items found", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ItemDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items/search", + produces = {"application/json"} + ) + default ResponseEntity> searchItems(@Parameter(name = "name", required = false, schema = @Schema(description = "")) @RequestParam(name = "name", required = false) String name , @Parameter(name = "page", required = false, schema = @Schema(description = "")) @RequestParam(name = "page", required = false) Integer page, @ApiIgnore final ServerWebExchange exchange) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * GET /items/{itemId} + * @param itemId true + * @return The item; (status code 200) No such item; (status code 404) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + @Operation( + operationId = "getItem", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The item", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ItemDTO.class))), + @ApiResponse(responseCode = "404", description = "No such item") + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items/{itemId}", + produces = {"application/json"} + ) + default ResponseEntity> getItem(@Parameter(name = "itemId", required = true, schema = @Schema(description = "")) @PathVariable("itemId") String itemId, @ApiIgnore final ServerWebExchange exchange) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * HEAD /items/{itemId} + * @param itemId true + * @return The item exists; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + @Operation( + operationId = "checkItem", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The item exists") + } + ) + @RequestMapping( + method = RequestMethod.HEAD, + value = "/items/{itemId}", + produces = {"application/json"} + ) + default ResponseEntity checkItem(@Parameter(name = "itemId", required = true, schema = @Schema(description = "")) @PathVariable("itemId") String itemId, @ApiIgnore final ServerWebExchange exchange) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * PURGE /items/{itemId} + * @param itemId true + * @return Purged from the caches; (status code 204) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + @Operation( + operationId = "purgeItem", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "204", description = "Purged from the caches") + } + ) + @HttpMethodMapping( + method = "PURGE", + value = "/items/{itemId}", + produces = {"application/json"} + ) + default ResponseEntity purgeItem(@Parameter(name = "itemId", required = true, schema = @Schema(description = "")) @PathVariable("itemId") String itemId, @ApiIgnore final ServerWebExchange exchange) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + +} \ No newline at end of file diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/restclient/ItemsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/restclient/ItemsApi.java new file mode 100644 index 00000000..051b8b36 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/restclient/ItemsApi.java @@ -0,0 +1,265 @@ +package com.sngular.multifileplugin.openapi32restclient; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.sngular.multifileplugin.openapi32restclient.client.ApiRestClient; + +import com.sngular.multifileplugin.openapi32restclient.model.FilterDTO; +import com.sngular.multifileplugin.openapi32restclient.model.ItemDTO; +import com.sngular.multifileplugin.openapi32restclient.model.CriteriaDTO; + +import com.sngular.multifileplugin.openapi32restclient.client.auth.Authentication; + +import org.springframework.stereotype.Component; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.HttpClientErrorException; + +@Component() +public class ItemsApi { + + private ApiRestClient apiRestClient; + + private Map authenticationsApi; + + private String basePath = ""; + + /** + * Builds its own ApiRestClient, sending requests to the contract's first server, and is the constructor Spring uses when the class is a component. + */ + public ItemsApi() { + this.init(); + } + + /** + * Sends requests through the given client, e.g. one built on the service's configured RestTemplate, to the contract's first server. + */ + public ItemsApi(final ApiRestClient apiRestClient) { + this.apiRestClient = Objects.requireNonNull(apiRestClient, "apiRestClient"); + } + + /** + * Sends requests through the given client to {@code basePath}. An empty base path sends them relative to the client's own + * root URI (RestTemplateBuilder.rootUri(...)). + */ + public ItemsApi(final ApiRestClient apiRestClient, final String basePath) { + this(apiRestClient); + this.basePath = basePath; + } + + public String getBasePath() { + return basePath; + } + + public void setBasePath(final String basePath) { + this.basePath = basePath; + } + + protected void init() { + this.authenticationsApi = new HashMap(); + this.apiRestClient = new ApiRestClient(authenticationsApi); + } + + /** + * QUERY /items: Finds the items that match a filter + * @param filterDTO (required) + * @return The matching items; (status code 200) + * @throws RestClientException if an error occurs while attempting to invoke the API + */ + public List queryItems(FilterDTO filterDTO) throws RestClientException { + return queryItemsWithHttpInfo(filterDTO).getBody(); + } + + public ResponseEntity> queryItemsWithHttpInfo(FilterDTO filterDTO) throws RestClientException { + + Object postBody = filterDTO; + if (filterDTO == null) { + throw new RestClientException(HttpStatus.BAD_REQUEST + " Missing the required parameter ''filterDTO'' when calling queryItems"); + } + final Map uriVariables = new HashMap(); + + final MultiValueMap queryParams = new LinkedMultiValueMap(); + final HttpHeaders headerParams = new HttpHeaders(); + final MultiValueMap cookieParams = new LinkedMultiValueMap(); + final MultiValueMap formParams = new LinkedMultiValueMap(); + + final String[] localVarAccepts = {"application/json"}; + final List localVarAccept = apiRestClient.selectHeaderAccept(localVarAccepts); + final String[] localVarContentTypes = {"application/json"}; + final MediaType localVarContentType = apiRestClient.selectHeaderContentType(localVarContentTypes); + + String[] localVarAuthNames = new String[] {}; + + ParameterizedTypeReference> localVarReturnType = new ParameterizedTypeReference>() {}; + return apiRestClient.invokeAPI(basePath,"/items", HttpMethod.valueOf("QUERY"), uriVariables, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + } + + /** + * OPTIONS /items + * @return The methods allowed; (status code 204) + * @throws RestClientException if an error occurs while attempting to invoke the API + */ + public void itemsOptions() throws RestClientException { + itemsOptionsWithHttpInfo(); + } + + public ResponseEntity itemsOptionsWithHttpInfo() throws RestClientException { + + Object postBody = null; + final Map uriVariables = new HashMap(); + + final MultiValueMap queryParams = new LinkedMultiValueMap(); + final HttpHeaders headerParams = new HttpHeaders(); + final MultiValueMap cookieParams = new LinkedMultiValueMap(); + final MultiValueMap formParams = new LinkedMultiValueMap(); + + final String[] localVarAccepts = {}; + final List localVarAccept = apiRestClient.selectHeaderAccept(localVarAccepts); + final String[] localVarContentTypes = {}; + final MediaType localVarContentType = apiRestClient.selectHeaderContentType(localVarContentTypes); + + String[] localVarAuthNames = new String[] {}; + + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return apiRestClient.invokeAPI(basePath,"/items", HttpMethod.OPTIONS, uriVariables, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + } + + /** + * GET /items/search + * @param criteria + * @return The items found; (status code 200) + * @throws RestClientException if an error occurs while attempting to invoke the API + */ + public List searchItems(CriteriaDTO criteria) throws RestClientException { + return searchItemsWithHttpInfo(criteria).getBody(); + } + + public ResponseEntity> searchItemsWithHttpInfo(CriteriaDTO criteria) throws RestClientException { + + Object postBody = null; + final Map uriVariables = new HashMap(); + + final MultiValueMap queryParams = new LinkedMultiValueMap(); + final HttpHeaders headerParams = new HttpHeaders(); + final MultiValueMap cookieParams = new LinkedMultiValueMap(); + final MultiValueMap formParams = new LinkedMultiValueMap(); + + queryParams.putAll(apiRestClient.objectToQueryParams("form", true, "criteria", criteria)); + final String[] localVarAccepts = {"application/json"}; + final List localVarAccept = apiRestClient.selectHeaderAccept(localVarAccepts); + final String[] localVarContentTypes = {}; + final MediaType localVarContentType = apiRestClient.selectHeaderContentType(localVarContentTypes); + + String[] localVarAuthNames = new String[] {}; + + ParameterizedTypeReference> localVarReturnType = new ParameterizedTypeReference>() {}; + return apiRestClient.invokeAPI(basePath,"/items/search", HttpMethod.GET, uriVariables, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + } + + /** + * GET /items/{itemId} + * @param itemId (required) + * @return The item; (status code 200) No such item; (status code 404) + * @throws RestClientException if an error occurs while attempting to invoke the API + */ + public ItemDTO getItem(String itemId) throws RestClientException { + return getItemWithHttpInfo(itemId).getBody(); + } + + public ResponseEntity getItemWithHttpInfo(String itemId) throws RestClientException { + + Object postBody = null; + final Map uriVariables = new HashMap(); + + uriVariables.put("itemId", itemId); + final MultiValueMap queryParams = new LinkedMultiValueMap(); + final HttpHeaders headerParams = new HttpHeaders(); + final MultiValueMap cookieParams = new LinkedMultiValueMap(); + final MultiValueMap formParams = new LinkedMultiValueMap(); + + final String[] localVarAccepts = {"application/json"}; + final List localVarAccept = apiRestClient.selectHeaderAccept(localVarAccepts); + final String[] localVarContentTypes = {}; + final MediaType localVarContentType = apiRestClient.selectHeaderContentType(localVarContentTypes); + + String[] localVarAuthNames = new String[] {}; + + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return apiRestClient.invokeAPI(basePath,"/items/{itemId}", HttpMethod.GET, uriVariables, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + } + + /** + * HEAD /items/{itemId} + * @param itemId (required) + * @return The item exists; (status code 200) + * @throws RestClientException if an error occurs while attempting to invoke the API + */ + public void checkItem(String itemId) throws RestClientException { + checkItemWithHttpInfo(itemId); + } + + public ResponseEntity checkItemWithHttpInfo(String itemId) throws RestClientException { + + Object postBody = null; + final Map uriVariables = new HashMap(); + + uriVariables.put("itemId", itemId); + final MultiValueMap queryParams = new LinkedMultiValueMap(); + final HttpHeaders headerParams = new HttpHeaders(); + final MultiValueMap cookieParams = new LinkedMultiValueMap(); + final MultiValueMap formParams = new LinkedMultiValueMap(); + + final String[] localVarAccepts = {}; + final List localVarAccept = apiRestClient.selectHeaderAccept(localVarAccepts); + final String[] localVarContentTypes = {}; + final MediaType localVarContentType = apiRestClient.selectHeaderContentType(localVarContentTypes); + + String[] localVarAuthNames = new String[] {}; + + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return apiRestClient.invokeAPI(basePath,"/items/{itemId}", HttpMethod.HEAD, uriVariables, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + } + + /** + * PURGE /items/{itemId} + * @param itemId (required) + * @return Purged from the caches; (status code 204) + * @throws RestClientException if an error occurs while attempting to invoke the API + */ + public void purgeItem(String itemId) throws RestClientException { + purgeItemWithHttpInfo(itemId); + } + + public ResponseEntity purgeItemWithHttpInfo(String itemId) throws RestClientException { + + Object postBody = null; + final Map uriVariables = new HashMap(); + + uriVariables.put("itemId", itemId); + final MultiValueMap queryParams = new LinkedMultiValueMap(); + final HttpHeaders headerParams = new HttpHeaders(); + final MultiValueMap cookieParams = new LinkedMultiValueMap(); + final MultiValueMap formParams = new LinkedMultiValueMap(); + + final String[] localVarAccepts = {}; + final List localVarAccept = apiRestClient.selectHeaderAccept(localVarAccepts); + final String[] localVarContentTypes = {}; + final MediaType localVarContentType = apiRestClient.selectHeaderContentType(localVarContentTypes); + + String[] localVarAuthNames = new String[] {}; + + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return apiRestClient.invokeAPI(basePath,"/items/{itemId}", HttpMethod.valueOf("PURGE"), uriVariables, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + } + +} \ No newline at end of file diff --git a/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/webclient/ItemsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/webclient/ItemsApi.java new file mode 100644 index 00000000..bcbe5033 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testOpenApi32/assets/webclient/ItemsApi.java @@ -0,0 +1,331 @@ +package com.sngular.multifileplugin.openapi32webclient; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.sngular.multifileplugin.openapi32webclient.client.ApiWebClient; +import com.sngular.multifileplugin.openapi32webclient.model.FilterDTO; +import com.sngular.multifileplugin.openapi32webclient.model.ItemDTO; +import com.sngular.multifileplugin.openapi32webclient.model.CriteriaDTO; + +import com.sngular.multifileplugin.openapi32webclient.client.auth.Authentication; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.client.WebClient.ResponseSpec; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class ItemsApi { + + private ApiWebClient apiWebClient; + + private Map authenticationsApi; + + private String basePath = ""; + + /** + * Builds its own ApiWebClient, sending requests to the contract's first server. + */ + public ItemsApi() { + this.init(); + } + + /** + * Sends requests through the given client, e.g. one built on the service's configured WebClient, to the contract's first server. + */ + public ItemsApi(final ApiWebClient apiWebClient) { + this.apiWebClient = Objects.requireNonNull(apiWebClient, "apiWebClient"); + } + + /** + * Sends requests through the given client to {@code basePath}. An empty base path sends them relative to the client's own + * base URL (WebClient.Builder.baseUrl(...)). + */ + public ItemsApi(final ApiWebClient apiWebClient, final String basePath) { + this(apiWebClient); + this.basePath = basePath; + } + + public String getBasePath() { + return basePath; + } + + public void setBasePath(final String basePath) { + this.basePath = basePath; + } + + protected void init() { + this.authenticationsApi = new HashMap(); + this.apiWebClient = new ApiWebClient(authenticationsApi); + } + + /** + * QUERY /items: Finds the items that match a filter + * @param filterDTO (required) + * @return The matching items; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + private ResponseSpec queryItemsRequestCreation(FilterDTO filterDTO) throws WebClientResponseException { + Object postBody = filterDTO; + if (filterDTO == null) { + throw new WebClientResponseException("Missing the required parameter ''filterDTO'' when calling queryItems", HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase(), null, null, null); + } + final Map pathParams = new HashMap(); + + final MultiValueMap queryParams = new LinkedMultiValueMap(); + final HttpHeaders headerParams = new HttpHeaders(); + final MultiValueMap cookieParams = new LinkedMultiValueMap(); + final MultiValueMap formParams = new LinkedMultiValueMap(); + final String[] localVarAccepts = {"application/json"}; + final List localVarAccept = apiWebClient.selectHeaderAccept(localVarAccepts); + final String[] localVarContentTypes = {"application/json"}; + final MediaType localVarContentType = apiWebClient.selectHeaderContentType(localVarContentTypes); + + String[] localVarAuthNames = new String[] {}; + + ParameterizedTypeReference> localVarReturnType = new ParameterizedTypeReference>() {}; + return apiWebClient.invokeAPI(basePath,"/items", HttpMethod.valueOf("QUERY"), pathParams, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + + } + + /** + * QUERY /items: Finds the items that match a filter + * @param filterDTO (required) + * @return The matching items; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + public Flux queryItems(FilterDTO filterDTO) throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return queryItemsRequestCreation(filterDTO).bodyToFlux(localVarReturnType); + } + + public Mono>> queryItemsWithHttpInfo(FilterDTO filterDTO) throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return queryItemsRequestCreation(filterDTO).toEntityList(localVarReturnType); + } + + /** + * OPTIONS /items: "" + * @return The methods allowed; (status code 204) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + private ResponseSpec itemsOptionsRequestCreation() throws WebClientResponseException { + Object postBody = null; + final Map pathParams = new HashMap(); + + final MultiValueMap queryParams = new LinkedMultiValueMap(); + final HttpHeaders headerParams = new HttpHeaders(); + final MultiValueMap cookieParams = new LinkedMultiValueMap(); + final MultiValueMap formParams = new LinkedMultiValueMap(); + final String[] localVarAccepts = {}; + final List localVarAccept = apiWebClient.selectHeaderAccept(localVarAccepts); + final String[] localVarContentTypes = {}; + final MediaType localVarContentType = apiWebClient.selectHeaderContentType(localVarContentTypes); + + String[] localVarAuthNames = new String[] {}; + + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return apiWebClient.invokeAPI(basePath,"/items", HttpMethod.OPTIONS, pathParams, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + + } + + /** + * OPTIONS /items + * @return The methods allowed; (status code 204) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + public Mono itemsOptions() throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return itemsOptionsRequestCreation().bodyToMono(localVarReturnType); + } + + public Mono> itemsOptionsWithHttpInfo() throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return itemsOptionsRequestCreation().toEntity(localVarReturnType); + } + + /** + * GET /items/search: "" + * @param criteria false + * @return The items found; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + private ResponseSpec searchItemsRequestCreation(CriteriaDTO criteria) throws WebClientResponseException { + Object postBody = null; + final Map pathParams = new HashMap(); + + final MultiValueMap queryParams = new LinkedMultiValueMap(); + final HttpHeaders headerParams = new HttpHeaders(); + final MultiValueMap cookieParams = new LinkedMultiValueMap(); + final MultiValueMap formParams = new LinkedMultiValueMap(); + + queryParams.putAll(apiWebClient.objectToQueryParams("form", true, "criteria", criteria)); + + final String[] localVarAccepts = {"application/json"}; + final List localVarAccept = apiWebClient.selectHeaderAccept(localVarAccepts); + final String[] localVarContentTypes = {}; + final MediaType localVarContentType = apiWebClient.selectHeaderContentType(localVarContentTypes); + + String[] localVarAuthNames = new String[] {}; + + ParameterizedTypeReference> localVarReturnType = new ParameterizedTypeReference>() {}; + return apiWebClient.invokeAPI(basePath,"/items/search", HttpMethod.GET, pathParams, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + + } + + /** + * GET /items/search + * @param criteria + * @return The items found; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + public Flux searchItems(CriteriaDTO criteria) throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return searchItemsRequestCreation(criteria).bodyToFlux(localVarReturnType); + } + + public Mono>> searchItemsWithHttpInfo(CriteriaDTO criteria) throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return searchItemsRequestCreation(criteria).toEntityList(localVarReturnType); + } + + /** + * GET /items/{itemId}: "" + * @param itemId true + * @return The item; (status code 200) No such item; (status code 404) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + private ResponseSpec getItemRequestCreation(String itemId) throws WebClientResponseException { + Object postBody = null; + final Map pathParams = new HashMap(); + + pathParams.put("itemId", itemId); + final MultiValueMap queryParams = new LinkedMultiValueMap(); + final HttpHeaders headerParams = new HttpHeaders(); + final MultiValueMap cookieParams = new LinkedMultiValueMap(); + final MultiValueMap formParams = new LinkedMultiValueMap(); + final String[] localVarAccepts = {"application/json"}; + final List localVarAccept = apiWebClient.selectHeaderAccept(localVarAccepts); + final String[] localVarContentTypes = {}; + final MediaType localVarContentType = apiWebClient.selectHeaderContentType(localVarContentTypes); + + String[] localVarAuthNames = new String[] {}; + + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return apiWebClient.invokeAPI(basePath,"/items/{itemId}", HttpMethod.GET, pathParams, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + + } + + /** + * GET /items/{itemId} + * @param itemId (required) + * @return The item; (status code 200) No such item; (status code 404) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + public Mono getItem(String itemId) throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return getItemRequestCreation(itemId).bodyToMono(localVarReturnType); + } + + public Mono> getItemWithHttpInfo(String itemId) throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return getItemRequestCreation(itemId).toEntity(localVarReturnType); + } + + /** + * HEAD /items/{itemId}: "" + * @param itemId true + * @return The item exists; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + private ResponseSpec checkItemRequestCreation(String itemId) throws WebClientResponseException { + Object postBody = null; + final Map pathParams = new HashMap(); + + pathParams.put("itemId", itemId); + final MultiValueMap queryParams = new LinkedMultiValueMap(); + final HttpHeaders headerParams = new HttpHeaders(); + final MultiValueMap cookieParams = new LinkedMultiValueMap(); + final MultiValueMap formParams = new LinkedMultiValueMap(); + final String[] localVarAccepts = {}; + final List localVarAccept = apiWebClient.selectHeaderAccept(localVarAccepts); + final String[] localVarContentTypes = {}; + final MediaType localVarContentType = apiWebClient.selectHeaderContentType(localVarContentTypes); + + String[] localVarAuthNames = new String[] {}; + + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return apiWebClient.invokeAPI(basePath,"/items/{itemId}", HttpMethod.HEAD, pathParams, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + + } + + /** + * HEAD /items/{itemId} + * @param itemId (required) + * @return The item exists; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + public Mono checkItem(String itemId) throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return checkItemRequestCreation(itemId).bodyToMono(localVarReturnType); + } + + public Mono> checkItemWithHttpInfo(String itemId) throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return checkItemRequestCreation(itemId).toEntity(localVarReturnType); + } + + /** + * PURGE /items/{itemId}: "" + * @param itemId true + * @return Purged from the caches; (status code 204) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + private ResponseSpec purgeItemRequestCreation(String itemId) throws WebClientResponseException { + Object postBody = null; + final Map pathParams = new HashMap(); + + pathParams.put("itemId", itemId); + final MultiValueMap queryParams = new LinkedMultiValueMap(); + final HttpHeaders headerParams = new HttpHeaders(); + final MultiValueMap cookieParams = new LinkedMultiValueMap(); + final MultiValueMap formParams = new LinkedMultiValueMap(); + final String[] localVarAccepts = {}; + final List localVarAccept = apiWebClient.selectHeaderAccept(localVarAccepts); + final String[] localVarContentTypes = {}; + final MediaType localVarContentType = apiWebClient.selectHeaderContentType(localVarContentTypes); + + String[] localVarAuthNames = new String[] {}; + + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return apiWebClient.invokeAPI(basePath,"/items/{itemId}", HttpMethod.valueOf("PURGE"), pathParams, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + + } + + /** + * PURGE /items/{itemId} + * @param itemId (required) + * @return Purged from the caches; (status code 204) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + public Mono purgeItem(String itemId) throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return purgeItemRequestCreation(itemId).bodyToMono(localVarReturnType); + } + + public Mono> purgeItemWithHttpInfo(String itemId) throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return purgeItemRequestCreation(itemId).toEntity(localVarReturnType); + } + +} \ No newline at end of file From ab59c61007b8f6fae9baa913782366e813d0b795 Mon Sep 17 00:00:00 2001 From: joseegarcia Date: Tue, 29 Sep 2026 16:24:22 +0200 Subject: [PATCH 2/2] Bump version to 8.4.0 Co-Authored-By: Claude Opus 5.5 (1M context) --- multiapi-engine/pom.xml | 2 +- scs-multiapi-gradle-plugin/build.gradle | 4 ++-- scs-multiapi-maven-plugin/pom.xml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/multiapi-engine/pom.xml b/multiapi-engine/pom.xml index 560692d4..03e3ba80 100644 --- a/multiapi-engine/pom.xml +++ b/multiapi-engine/pom.xml @@ -4,7 +4,7 @@ com.sngular multiapi-engine - 8.3.1 + 8.4.0 jar diff --git a/scs-multiapi-gradle-plugin/build.gradle b/scs-multiapi-gradle-plugin/build.gradle index d2d73075..f41d1e81 100644 --- a/scs-multiapi-gradle-plugin/build.gradle +++ b/scs-multiapi-gradle-plugin/build.gradle @@ -21,7 +21,7 @@ repositories { } group = 'com.sngular' -version = '8.3.1' +version = '8.4.0' def SCSMultiApiPluginGroupId = group def SCSMultiApiPluginVersion = version @@ -31,7 +31,7 @@ dependencies { shadow localGroovy() shadow gradleApi() - implementation 'com.sngular:multiapi-engine:8.3.1' + implementation 'com.sngular:multiapi-engine:8.4.0' testImplementation 'org.assertj:assertj-core:3.24.2' testImplementation 'com.puppycrawl.tools:checkstyle:10.12.3' testImplementation 'org.junit.platform:junit-platform-launcher:1.9.2' diff --git a/scs-multiapi-maven-plugin/pom.xml b/scs-multiapi-maven-plugin/pom.xml index 8cb25044..fbe4f2c3 100644 --- a/scs-multiapi-maven-plugin/pom.xml +++ b/scs-multiapi-maven-plugin/pom.xml @@ -4,7 +4,7 @@ com.sngular scs-multiapi-maven-plugin - 8.3.1 + 8.4.0 maven-plugin AsyncApi - OpenApi Code Generator Maven Plugin @@ -272,7 +272,7 @@ com.sngular multiapi-engine - 8.3.1 + 8.4.0 org.apache.maven