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/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/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 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