diff --git a/README.md b/README.md index 22d1b80f..6837463e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Maven and Gradle - [Usage](#usage) - [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 (application/x-ndjson)](#streaming-endpoints-applicationx-ndjson) - [Camel case Java names (useCamelCaseNames)](#camel-case-java-names-usecamelcasenames) - [Unknown enum values (useUnknownEnumValue)](#unknown-enum-values-useunknownenumvalue) - [Property Validation](#property-validation) @@ -1092,6 +1093,66 @@ own: every generated API takes one argument per part (`MultipartFile file, String comment`). A multipart body that references a component schema keeps taking that model. +### Streaming endpoints (application/x-ndjson) + +An operation whose successful response, or whose request body, is +`application/x-ndjson` (or the older `application/stream+json`) streams one JSON +document per line. Its schema is the type of each item, or an array of it, and +an OpenAPI 3.2 `itemSchema` is read the same way: + +```yaml +responses: + '200': + content: + application/x-ndjson: + schema: + $ref: "#/components/schemas/Item" +``` + +The operation is detected from the contract, so the rest of the API keeps its +usual signatures, and a Spring MVC service does not have to become reactive +for it. + +- **Spring MVC servers** (`reactive` off) split the operation in two. The + endpoint Spring maps sends what a plain Java method returns, and that method + is the one to implement: + + ```java + @RestController + class ItemController implements ItemsApi { + + @Override + public ResponseEntity> listItems(final String filter) { + return ResponseEntity.ok(itemRepository.streamByFilter(filter)); + } + } + ``` + + Each item is sent as soon as the stream produces it, on Spring MVC's + asynchronous executor, with the application's `ObjectMapper`, and the stream + is closed once it is sent, also when sending fails. The status and headers of + the returned `ResponseEntity` are sent too. A streamed request body arrives as + a `Stream` read line by line as it is consumed. The generated + `NdjsonSupport` class, next to the interfaces, does the writing and reading. + + When the response also declares `application/json`, which must then be the + array of the streamed items, a second endpoint sends the same items as a JSON + array, and Spring picks one or the other from the `Accept` header (a request + that accepts anything, or sends no `Accept`, gets the JSON array). + + The response status is sent with the first item, so an error in the middle + of the stream can only end the response early. Configure + `spring.mvc.async.request-timeout` for streams that last long. +- **WebFlux servers** (`reactive` on) take and return a `Flux`. +- **WebClient clients** (`callMode` and `reactive`) return a `Flux`, + or a `Mono>>` from `...WithHttpInfo`, and send a + streamed body from a `Flux`. **Reactive `@HttpExchange` interfaces** + return a `Mono>>`. +- The **blocking clients** (RestClient, and `@HttpExchange` without `reactive`) + read a response whole, so they cannot hand the items over as they arrive, and + the generation fails for a streaming operation: generate that client with + `reactive` on. + ### 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 c903644e..66440ce9 100644 --- a/multiapi-engine/pom.xml +++ b/multiapi-engine/pom.xml @@ -4,7 +4,7 @@ com.sngular multiapi-engine - 8.2.1 + 8.3.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 1770f5ab..8c24a2a8 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 @@ -200,6 +200,7 @@ 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.promoteItemSchemas(openAPI); final String clientPackage = specFile.getClientPackage(); validateClientOptions(specFile); diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/model/ContentObject.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/model/ContentObject.java index b813ccc3..6991f20c 100644 --- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/model/ContentObject.java +++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/model/ContentObject.java @@ -27,4 +27,10 @@ public class ContentObject { private SchemaObject schemaObject; + /** + * Whether the media type streams a sequence of documents (such as {@code application/x-ndjson}). The data type is then the + * type of each streamed item. + */ + private boolean streaming; + } 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 9f20fb70..d1a7d51e 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 @@ -9,6 +9,8 @@ import java.util.ArrayList; import java.util.List; +import com.sngular.api.generator.plugin.common.model.SchemaFieldObjectType; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -38,6 +40,18 @@ public final class OperationObject { private List securities; + /** Whether the successful response streams its items (such as {@code application/x-ndjson}). */ + private boolean streamingResponse; + + /** The type of each item the successful response streams. */ + private SchemaFieldObjectType streamingItemType; + + /** The media type the successful response streams its items as. */ + private String streamingMediaType; + + /** Whether the successful response can also be sent as a JSON array of the streamed items. */ + private boolean streamingAlsoJson; + public static final class OperationObjectBuilder { private final List tags = new ArrayList<>(); diff --git a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/model/RequestObject.java b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/model/RequestObject.java index ab2b269a..80fa11c1 100644 --- a/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/model/RequestObject.java +++ b/multiapi-engine/src/main/java/com/sngular/api/generator/plugin/openapi/model/RequestObject.java @@ -32,6 +32,9 @@ public class RequestObject { */ private boolean inlineMultipart; + /** Whether the body streams its items (such as {@code application/x-ndjson}); its content's data type is then the item's. */ + private boolean streaming; + public static final class RequestObjectBuilder { private final List contentObjects = new ArrayList<>(); 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 cbf7dc5a..d5e21161 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,7 @@ 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_CALL_HTTP_EXCHANGE_API, TemplateIndexConstants.TEMPLATE_NDJSON_SUPPORT); 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 82501aab..c9bf7de2 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 @@ -14,6 +14,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.PathObject; +import com.sngular.api.generator.plugin.openapi.model.RequestObject; import com.sngular.api.generator.plugin.openapi.parameter.SpecFile; import org.apache.commons.lang3.StringUtils; @@ -89,6 +90,18 @@ public final void fillTemplate( writeTemplateToFile(specFile.isCallMode() ? getTemplateClientApi(specFile) : getTemplateApi(specFile), StringUtils.defaultIfEmpty(specFile.getApiPackage(), DEFAULT_API_PACKAGE), className + "Api"); + + // 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"); + } + } + + private static boolean streams(final List pathObjects) { + return pathObjects.stream() + .flatMap(path -> path.getOperationObjects().stream()) + .anyMatch(operation -> operation.isStreamingResponse() || operation.getRequestObjects().stream().anyMatch(RequestObject::isStreaming)); } private String getTemplateClientApi(final SpecFile specFile) { 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 f525aae9..dc72faed 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 @@ -20,6 +20,8 @@ public class TemplateIndexConstants extends CommonTemplateIndexConstants { public static final String TEMPLATE_CALL_HTTP_EXCHANGE_API = "templateCallHttpExchange.ftlh"; + public static final String TEMPLATE_NDJSON_SUPPORT = "templateNdjsonSupport.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 af3c052e..3a56b311 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 @@ -13,10 +13,12 @@ import java.util.HashMap; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.function.BiConsumer; import com.fasterxml.jackson.databind.JsonNode; @@ -28,6 +30,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.exception.DuplicatedOperationException; import com.sngular.api.generator.plugin.openapi.exception.InvalidOpenAPIException; import com.sngular.api.generator.plugin.openapi.model.AuthSchemaObject; @@ -57,6 +60,14 @@ public class MapperPathUtil { public static final String SCHEMA = "schema"; + /** + * The media types that stream a sequence of JSON documents, one per line, which Spring reads and writes item by item. Their + * schema is the type of each item, or an array of it. + */ + private static final Set STREAMING_MEDIA_TYPES = Set.of("application/x-ndjson", "application/stream+json"); + + private static final String JSON_MEDIA_TYPE = "application/json"; + private MapperPathUtil() { } @@ -152,7 +163,7 @@ private static OperationObject createOperation( final JsonNode operation, final String operationType, final SpecFile specFile, final GlobalObject globalObject, final List operationIdList, final Path baseDir) { final JsonNode tagsNode = operation.has("tags") ? operation.get("tags") : null; - return OperationObject.builder() + final OperationObject operationObject = OperationObject.builder() .operationId(mapOperationId(getOperationId(operation), operationIdList)) .operationType(operationType) .summary(ApiTool.getNodeAsString(operation, "summary")) @@ -165,6 +176,58 @@ private static OperationObject createOperation( .consumes(getRequestList(operation.at("/requestBody"))) .produces(getResponseList(operation.at("/responses"))) .build(); + applyStreaming(operationObject, specFile); + return operationObject; + } + + public static boolean isStreamingMediaType(final String mediaType) { + return Objects.nonNull(mediaType) && STREAMING_MEDIA_TYPES.contains(mediaType.toLowerCase(Locale.ROOT)); + } + + /** + * Marks the operation whose successful response streams its items, and checks that the contract can be generated as a + * stream: a JSON alternative of the same response must be the array of the streamed items, and a streamed body its only + * content. + */ + private static void applyStreaming(final OperationObject operation, final SpecFile specFile) { + final Optional streamedResponse = operation.getResponseObjects().stream() + .filter(response -> StringUtils.startsWith(response.getResponseName(), "2")) + .filter(response -> response.getContentObjects().stream().anyMatch(ContentObject::isStreaming)) + .findFirst(); + streamedResponse.ifPresent(response -> { + final ContentObject content = response.getContentObjects().stream().filter(ContentObject::isStreaming).findFirst().orElseThrow(); + operation.setStreamingResponse(true); + operation.setStreamingItemType(content.getDataType()); + operation.setStreamingMediaType(content.getName()); + operation.setStreamingAlsoJson(hasJsonAlternative(operation.getOperationId(), response, content)); + }); + for (final RequestObject request : operation.getRequestObjects()) { + if (request.isStreaming() && request.getContentObjects().size() > 1) { + throw new CodeGenerationException("Operation '" + operation.getOperationId() + "' streams its request body, which must then declare only the " + + "streamed media type, but it declares " + request.getContentObjects().stream().map(ContentObject::getName).toList()); + } + } + final boolean streams = operation.isStreamingResponse() || operation.getRequestObjects().stream().anyMatch(RequestObject::isStreaming); + if (streams && specFile.isCallMode() && !specFile.isReactive()) { + throw new CodeGenerationException("Operation '" + operation.getOperationId() + "' streams application/x-ndjson, which the blocking clients cannot " + + "consume: generate its client with reactive=true (a WebClient client or a reactive @HttpExchange interface)"); + } + } + + /** Whether the streamed response also declares JSON, which then has to be the array of the streamed items. */ + private static boolean hasJsonAlternative(final String operationId, final ResponseObject response, final ContentObject streamed) { + final Optional json = response.getContentObjects().stream() + .filter(content -> JSON_MEDIA_TYPE.equalsIgnoreCase(content.getName())) + .findFirst(); + json.ifPresent(content -> { + final SchemaFieldObjectType jsonType = content.getDataType(); + if (Objects.isNull(jsonType) || !TypeConstants.ARRAY.equals(jsonType.getBaseType()) || Objects.isNull(jsonType.getInnerType()) + || !jsonType.getInnerType().toString().equals(String.valueOf(streamed.getDataType()))) { + throw new CodeGenerationException("Operation '" + operationId + "' streams " + streamed.getDataType() + " items as " + + streamed.getName() + ", so its application/json response must be the array of them, but it is " + jsonType); + } + }); + return json.isPresent(); } private static String getOperationId(final JsonNode operation) { @@ -235,6 +298,10 @@ private static boolean isInlineMultipart(final JsonNode content) { return !multipartSchema.isMissingNode() && !ApiTool.hasRef(multipartSchema); } + private static boolean isStreamingContent(final JsonNode content) { + return Objects.nonNull(content) && IteratorUtils.toList(content.fieldNames()).stream().anyMatch(MapperPathUtil::isStreamingMediaType); + } + private static List mapRequestObject( final SpecFile specFile, final JsonNode operation, final GlobalObject globalObject, final Path baseDir) { @@ -251,6 +318,7 @@ private static List mapRequestObject( .required(isRequiredBody(requestBody)) .isFormData(ApiTool.getNode(requestBody, CONTENT).has("multipart/form-data")) .inlineMultipart(isInlineMultipart(ApiTool.getNode(requestBody, CONTENT))) + .streaming(isStreamingContent(ApiTool.getNode(requestBody, CONTENT))) .contentObjects(mapContentObject(specFile, ApiTool.getNode(requestBody, CONTENT), "InlineObject" + operationIdWithCap, globalObject, baseDir)) .build()); @@ -264,6 +332,7 @@ private static List mapRequestObject( .required(isRequiredBody(actualRequestBody)) .isFormData(ApiTool.getNode(actualRequestBody, CONTENT).has("multipart/form-data")) .inlineMultipart(isInlineMultipart(ApiTool.getNode(actualRequestBody, CONTENT))) + .streaming(isStreamingContent(ApiTool.getNode(actualRequestBody, CONTENT))) .contentObjects(mapContentObject(specFile, ApiTool.getNode(actualRequestBody, CONTENT), operationIdWithCap, globalObject, baseDir)) .build()); @@ -500,7 +569,8 @@ private static List mapContentObject( if (Objects.nonNull(content)) { for (final Iterator it = content.fieldNames(); it.hasNext(); ) { final String mediaType = it.next(); - final var schema = ApiTool.getNode(ApiTool.getNode(content, mediaType), SCHEMA); + final boolean streaming = isStreamingMediaType(mediaType); + final var schema = streamedItemSchema(ApiTool.getNode(ApiTool.getNode(content, mediaType), SCHEMA), streaming); final String pojoName = preparePojoName(inlineObject, schema, specFile); final SchemaFieldObjectType dataType = getSchemaType(schema, pojoName, specFile, globalObject, baseDir); final String importName = getImportFromType(dataType); @@ -517,12 +587,18 @@ private static List mapContentObject( .name(mediaType) .importName(importName) .schemaObject(schemaObject) + .streaming(streaming) .build()); } } return contentObjects; } + /** The schema of each item a streamed media type sends: its schema, or the items of it when it is declared as an array. */ + private static JsonNode streamedItemSchema(final JsonNode schema, final boolean streaming) { + return streaming && Objects.nonNull(schema) && ApiTool.isArray(schema) && ApiTool.hasItems(schema) ? ApiTool.getItems(schema) : schema; + } + private static String preparePojoName(final String inlineObject, final JsonNode schema, final SpecFile specFile) { // Use unified ResponseWrapperHandler for wrapper decisions (v7.0) // This ensures OpenApiUtil and MapperPathUtil stay in sync 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 e6f3e278..4898057f 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 @@ -134,6 +134,27 @@ public static void mergeWebhooksIntoPaths(final JsonNode openApi) { } } + /** + * 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 + * as the type of the content, which is how a streamed {@code schema} is read too. + */ + public static void promoteItemSchemas(final JsonNode node) { + if (node instanceof ObjectNode) { + final JsonNode content = node.get("content"); + if (content instanceof ObjectNode) { + content.fields().forEachRemaining(mediaType -> { + if (mediaType.getValue() instanceof ObjectNode && mediaType.getValue().has("itemSchema") && !mediaType.getValue().has("schema")) { + ((ObjectNode) mediaType.getValue()).set("schema", mediaType.getValue().get("itemSchema")); + } + }); + } + } + if (Objects.nonNull(node) && node.isContainerNode()) { + node.elements().forEachRemaining(OpenApiUtil::promoteItemSchemas); + } + } + /** * Ensures every operation of a webhook-derived Path Item carries a {@code tags} entry. Webhook * operations normally omit {@code tags}, but the path pipeline requires one; a missing/empty diff --git a/multiapi-engine/src/main/resources/templates/openapi/template.ftlh b/multiapi-engine/src/main/resources/templates/openapi/template.ftlh index baddcb54..8c45b92e 100644 --- a/multiapi-engine/src/main/resources/templates/openapi/template.ftlh +++ b/multiapi-engine/src/main/resources/templates/openapi/template.ftlh @@ -1,4 +1,120 @@ <#ftl output_format="plainText"> +<#function streamsRequest operation><#return operation.requestObjects?has_content && operation.requestObjects[0].streaming> +<#function isStreaming operation><#return operation.streamingResponse || streamsRequest(operation)> +<#macro boundParameter parameter><@compress single_line=true>@Parameter(name = "${parameter.name}", <#if parameter.description?has_content>description = "${parameter.description}", required = ${parameter.required?c}, schema = @Schema(description = "")) + <#if parameter.in == "path">@PathVariable("${parameter.name}")<#elseif parameter.in == "query">@RequestParam(name = "${parameter.name}", required = ${parameter.required?c}<#if parameter.defaultValue??>, defaultValue = "${parameter.defaultValue?j_string}")<#elseif parameter.in == "header">@RequestHeader(name = "${parameter.name}", required = ${parameter.required?c}<#if parameter.defaultValue??>, defaultValue = "${parameter.defaultValue?j_string}")<#elseif parameter.in == "cookie">@CookieValue(name = "${parameter.name}", required = ${parameter.required?c}<#if parameter.defaultValue??>, defaultValue = "${parameter.defaultValue?j_string}")<#if parameter.dateTimeFormatIso??> @DateTimeFormat(iso = DateTimeFormat.ISO.${parameter.dateTimeFormatIso}) ${parameter.dataType} ${parameter.variableName} +<#-- The arguments of the endpoint of a streaming operation: its bound parameters and body, and the request it reads or streams through. --> +<#function streamingEndpointArgs operation path withRequest withResponse> + <#local args = []> + <#list (operation.parameterObjects![]) + (path.parameterObjects![]) as parameter><#local bound><@boundParameter parameter/><#local args = args + [bound]> + <#list operation.requestObjects as request> + <#if request.isFormData> + <#list request.contentObjects[0].schemaObject.fieldObjectList as part><#local args = args + ["@Parameter(name = \"${part.baseName}\", required = ${part.required?c}, schema = @Schema(description = \"\")) @RequestPart(value = \"${part.baseName}\", required = ${part.required?c}) ${part.dataType} ${part.fieldName}"]> + <#elseif !request.streaming> + <#local args = args + ["@Parameter(name = \"${request.contentObjects[0].dataType?api.getVariableNameString()}\", description = \"${request.description! \"\"}\", required = ${request.required?c}, schema = @Schema(description = \"${request.contentObjects[0].description! \"\"}\")) @Valid @RequestBody" + request.required?then("", "(required = false)") + " ${request.contentObjects[0].dataType} ${request.contentObjects[0].dataType?api.getVariableNameString()}"]> + + + <#if withRequest><#local args = args + ["@Parameter(hidden = true) final HttpServletRequest servletRequest"]> + <#if withResponse><#local args = args + ["@Parameter(hidden = true) final HttpServletResponse servletResponse"]> + <#return args?join(", ")> + +<#-- The parameters of the method a streaming operation is implemented in: the endpoint's, unbound, with the streamed body as a Stream of its items. --> +<#function streamingMethodParams operation path> + <#local params = []> + <#list (operation.parameterObjects![]) + (path.parameterObjects![]) as parameter><#local params = params + ["final ${parameter.dataType} ${parameter.variableName}"]> + <#list operation.requestObjects as request> + <#if request.isFormData> + <#list request.contentObjects[0].schemaObject.fieldObjectList as part><#local params = params + ["final ${part.dataType} ${part.fieldName}"]> + <#elseif request.streaming> + <#local params = params + ["final Stream<${request.contentObjects[0].dataType}> ${request.contentObjects[0].dataType?api.getVariableNameString()}"]> + <#else> + <#local params = params + ["final ${request.contentObjects[0].dataType} ${request.contentObjects[0].dataType?api.getVariableNameString()}"]> + + + <#return params?join(", ")> + +<#function streamingMethodCall operation path> + <#local args = []> + <#list (operation.parameterObjects![]) + (path.parameterObjects![]) as parameter><#local args = args + [parameter.variableName]> + <#list operation.requestObjects as request> + <#if request.isFormData> + <#list request.contentObjects[0].schemaObject.fieldObjectList as part><#local args = args + [part.fieldName]> + <#elseif request.streaming> + <#local args = args + ["NdjsonSupport.read(servletRequest, new TypeReference<${request.contentObjects[0].dataType}>() {})"]> + <#else> + <#local args = args + [request.contentObjects[0].dataType?api.getVariableNameString()]> + + + <#return operation.operationId + "(" + args?join(", ") + ")"> + +<#macro operationAnnotation path operation> + @Operation( + operationId = "${operation.operationId}",<#if operation.summary?has_content> + summary = "${operation.summary}", + tags = {<#list operation.tags as tag>"${tag}"}<#if operation.responseObjects?has_content>, + responses = { +<#list operation.responseObjects as response> @ApiResponse(responseCode = "${response.responseName}", description = "${response.description}"<#if response.contentObjects?has_content>, content = <#if (response.contentObjects?size > 1)>{<#list response.contentObjects as content>@Content(mediaType = "${content.name}", schema = @Schema(implementation = ${content.dataType?api.getClassString()}))<#sep>, <#if (response.contentObjects?size > 1)>})<#if !response?is_last>, + + } + ) + +<#-- A streaming operation: an endpoint per media type, which binds the request and sends the response, around the method the operation is implemented in. --> +<#macro streamingOperation path operation> +<#assign streamedType = operation.streamingResponse?then("Stream<${operation.streamingItemType}>", (operation.responseObjects[0].contentObjects[0]??)?then("${operation.responseObjects[0].contentObjects[0].dataType}", "Void"))> +<#assign consumesStream = streamsRequest(operation)?then(",\n consumes = {\"${operation.consumes[0]}\"}", (operation.requestObjects?has_content && operation.requestObjects[0].isFormData)?then(",\n consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}", ""))> +<#assign throwsIo = streamsRequest(operation)?then(" throws IOException", "")> +<#if operation.streamingResponse> + /** + * ${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}, + value = "${path.pathName}", + produces = {"${operation.streamingMediaType}"}${consumesStream} + ) + default ResponseEntity ${operation.operationId}Ndjson(${streamingEndpointArgs(operation, path, true, true)})${throwsIo} { + return NdjsonSupport.stream(${streamingMethodCall(operation, path)}, servletRequest, servletResponse); + } + + <#if operation.streamingAlsoJson> + /** + * ${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}, + value = "${path.pathName}", + produces = {"application/json"}${consumesStream} + ) + default ResponseEntity> ${operation.operationId}Json(${streamingEndpointArgs(operation, path, streamsRequest(operation), false)})${throwsIo} { + return NdjsonSupport.collect(${streamingMethodCall(operation, path)}); + } + + +<#else> + /** + * ${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}, + value = "${path.pathName}", + produces = {"application/json"}${consumesStream} + ) + default ResponseEntity<${streamedType}> ${operation.operationId}Ndjson(${streamingEndpointArgs(operation, path, true, false)})${throwsIo} { + return ${streamingMethodCall(operation, path)}; + } + + + /** + * ${operation.operationType} ${path.pathName}<#if operation.summary?has_content>: ${operation.summary}. Implement this method rather than the endpoint<#if operation.streamingAlsoJson>s above<#if operation.streamingResponse>: the items are sent as they are produced, and the stream is closed once they are sent<#if streamsRequest(operation)><#if operation.streamingResponse>;<#else>: the body items are read as the stream is consumed. + */ + default ResponseEntity<${streamedType}> ${operation.operationId}(${streamingMethodParams(operation, path)}) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + package <#if packageApi??>${packageApi}<#elseif package??> ${package}; <#assign imports=[]> @@ -102,6 +218,23 @@ import org.springframework.web.multipart.MultipartFile; <#if needsResource> import org.springframework.core.io.Resource; +<#assign streamingResponse=false> +<#assign streamingRequest=false> +<#list pathObjects as path><#list path.operationObjects as operation><#if operation.streamingResponse><#assign streamingResponse=true><#if streamsRequest(operation)><#assign streamingRequest=true> +<#if streamingResponse || streamingRequest> +import java.util.stream.Stream; +import ${javaEEPackage}.servlet.http.HttpServletRequest; + +<#if streamingResponse> +import ${javaEEPackage}.servlet.http.HttpServletResponse; + +<#if streamingRequest> +import java.io.IOException; +import ${jacksonPackage}.core.type.TypeReference; + +<#if streamingResponse> +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + <#list model_imports as import> import <#if packageModel??>${packageModel}<#elseif package??>${package}.${import}; @@ -111,6 +244,9 @@ public interface ${className?cap_first}Api { <#list pathObjects as path> <#list path.operationObjects as operation> +<#if isStreaming(operation)> +<@streamingOperation path operation/> +<#else> /** * ${operation.operationType} ${path.pathName}<#if operation.summary?has_content>: ${operation.summary}<#if operation.responseObjects?has_content> <#if operation.parameterObjects?has_content> @@ -130,7 +266,7 @@ public interface ${className?cap_first}Api { summary = "${operation.summary}", tags = {<#list operation.tags as tag>"${tag}"}<#if operation.responseObjects?has_content>, responses = { -<#list operation.responseObjects as response> @ApiResponse(responseCode = "${response.responseName}", description = "${response.description}"<#if response.contentObjects?has_content>, content = @Content(<#list response.contentObjects as content>mediaType = "${content.name}", schema = @Schema(implementation = ${content.dataType?api.getClassString()})))<#if !response?is_last>, +<#list operation.responseObjects as response> @ApiResponse(responseCode = "${response.responseName}", description = "${response.description}"<#if response.contentObjects?has_content>, content = <#if (response.contentObjects?size > 1)>{<#list response.contentObjects as content>@Content(mediaType = "${content.name}", schema = @Schema(implementation = ${content.dataType?api.getClassString()}))<#sep>, <#if (response.contentObjects?size > 1)>})<#if !response?is_last>, } ) @@ -149,6 +285,7 @@ public interface ${className?cap_first}Api { ${request.contentObjects[0].dataType} ${request.contentObjects[0].dataType?api.getVariableNameString()} <#if request?has_next>, ) { return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); } + diff --git a/multiapi-engine/src/main/resources/templates/openapi/templateCallHttpExchange.ftlh b/multiapi-engine/src/main/resources/templates/openapi/templateCallHttpExchange.ftlh index d3de136e..7bd4fab5 100644 --- a/multiapi-engine/src/main/resources/templates/openapi/templateCallHttpExchange.ftlh +++ b/multiapi-engine/src/main/resources/templates/openapi/templateCallHttpExchange.ftlh @@ -61,7 +61,9 @@ import ${import}; <#function returnType operation> - <#if operation.responseObjects?has_content && operation.responseObjects[0].contentObjects[0]??> + <#if operation.streamingResponse> + <#assign entity = "ResponseEntity>"> + <#elseif operation.responseObjects?has_content && operation.responseObjects[0].contentObjects[0]??> <#assign entity = "ResponseEntity<" + operation.responseObjects[0].contentObjects[0].dataType + ">"> <#else> <#assign entity = "ResponseEntity"> @@ -100,6 +102,11 @@ 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; +<#assign useFlux = false> +<#list pathObjects as path><#list path.operationObjects as operation><#if operation.streamingResponse><#assign useFlux = true><#list operation.requestObjects as request><#if request.streaming><#assign useFlux = true> +<#if isReactive && useFlux> +import reactor.core.publisher.Flux; + <#if isReactive> import reactor.core.publisher.Mono; @@ -138,8 +145,8 @@ public interface ${className?cap_first}Api { * @return<#list operation.responseObjects as response><#if response.responseName != "default"> ${response.description!""} (status code ${response.responseName}); */ - @${exchangeAnnotation(operation.operationType)}(<#if exchangeAnnotation(operation.operationType) == "HttpExchange">method = "${operation.operationType?upper_case}", url = "${path.pathName}"<#if operation.produces?has_content>, accept = {<#list operation.produces as produce>"${produce}"<#sep>, }<#if operation.requestObjects?has_content && operation.consumes?has_content>, contentType = "${operation.consumes[0]}") - ${returnType(operation)} ${operation.operationId}(<#list (operation.parameterObjects![]) + (path.parameterObjects![]) as parameter><@parameterBinding parameter/><#sep>, <#if operation.requestObjects?has_content><#if (operation.parameterObjects![])?has_content || (path.parameterObjects![])?has_content>, <#list operation.requestObjects as request><#if request.isFormData><#list request.contentObjects[0].schemaObject.fieldObjectList as part>@RequestPart(name = "${part.baseName}", required = ${part.required?c}) ${part.dataType} ${part.fieldName}<#sep>, <#else>@RequestBody(required = ${request.required?c}) ${request.contentObjects[0].dataType} ${request.contentObjects[0].dataType?api.getVariableNameString()}<#sep>, ); + @${exchangeAnnotation(operation.operationType)}(<#if exchangeAnnotation(operation.operationType) == "HttpExchange">method = "${operation.operationType?upper_case}", url = "${path.pathName}"<#if operation.streamingResponse>, accept = {"${operation.streamingMediaType}"}<#elseif operation.produces?has_content>, accept = {<#list operation.produces as produce>"${produce}"<#sep>, }<#if operation.requestObjects?has_content && operation.consumes?has_content>, contentType = "${operation.consumes[0]}") + ${returnType(operation)} ${operation.operationId}(<#list (operation.parameterObjects![]) + (path.parameterObjects![]) as parameter><@parameterBinding parameter/><#sep>, <#if operation.requestObjects?has_content><#if (operation.parameterObjects![])?has_content || (path.parameterObjects![])?has_content>, <#list operation.requestObjects as request><#if request.isFormData><#list request.contentObjects[0].schemaObject.fieldObjectList as part>@RequestPart(name = "${part.baseName}", required = ${part.required?c}) ${part.dataType} ${part.fieldName}<#sep>, <#else>@RequestBody(required = ${request.required?c}) <#if request.streaming>Flux<${request.contentObjects[0].dataType}><#else>${request.contentObjects[0].dataType} ${request.contentObjects[0].dataType?api.getVariableNameString()}<#sep>, ); } diff --git a/multiapi-engine/src/main/resources/templates/openapi/templateCallWebClient.ftlh b/multiapi-engine/src/main/resources/templates/openapi/templateCallWebClient.ftlh index 0e85da0c..cb813d46 100644 --- a/multiapi-engine/src/main/resources/templates/openapi/templateCallWebClient.ftlh +++ b/multiapi-engine/src/main/resources/templates/openapi/templateCallWebClient.ftlh @@ -5,6 +5,8 @@ <#list operation.requestObjects as request> <#if request.inlineMultipart> <#list request.contentObjects[0].schemaObject.fieldObjectList as part><#local args = args + ["${part.dataType} ${part.fieldName}"]> + <#elseif request.streaming> + <#local args = args + ["Flux<${request.contentObjects[0].dataType}> ${request.contentObjects[0].dataType?api.getVariableNameString()}"]> <#else> <#list request.contentObjects as content><#local args = args + ["${content.dataType} ${content.dataType?api.getVariableNameString()}"]> @@ -267,7 +269,7 @@ public class ${className?cap_first}Api { - final String[] localVarAccepts = {<#compress><#if operation.produces?has_content> + final String[] localVarAccepts = {<#compress><#if operation.streamingResponse>"${operation.streamingMediaType}"<#elseif operation.produces?has_content> <#list operation.produces as produce>"${produce}"<#if produce?has_next>, }; final List localVarAccept = apiWebClient.selectHeaderAccept(localVarAccepts); @@ -282,7 +284,9 @@ public class ${className?cap_first}Api { <#list operation.securities as security>"${security}"<#if security?has_next>, }; - <#if operation.responseObjects[0].contentObjects[0]??> + <#if operation.streamingResponse> + ParameterizedTypeReference<${operation.streamingItemType}> localVarReturnType = new ParameterizedTypeReference<${operation.streamingItemType}>() {}; + <#elseif operation.responseObjects[0].contentObjects[0]??> ParameterizedTypeReference<${operation.responseObjects[0].contentObjects[0].dataType}> localVarReturnType = new ParameterizedTypeReference<${operation.responseObjects[0].contentObjects[0].dataType}>() {}; <#else> ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; @@ -313,13 +317,15 @@ public class ${className?cap_first}Api { * @throws WebClientResponseException if an error occurs while attempting to invoke the API */ - public <@compress single_line=true><#if operation.responseObjects[0].contentObjects[0]??> + public <@compress single_line=true><#if operation.streamingResponse>Flux<${operation.streamingItemType}><#elseif 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>Mono<${operation.responseObjects[0].contentObjects[0].dataType}> <#else>Mono ${operation.operationId}(<@compress single_line=true> <#if operation.parameterObjects?has_content><#list operation.parameterObjects as parameter>${parameter.dataType} ${parameter.variableName}<#if parameter?has_next || operation.requestObjects?has_content>, <#if path.parameterObjects?has_content><#list path.parameterObjects as parameter>${parameter.dataType} ${parameter.variableName}<#if parameter?has_next || operation.requestObjects?has_content>, <#if operation.requestObjects?has_content>${requestArgDecls(operation)}) throws WebClientResponseException { - <#if operation.responseObjects[0].contentObjects[0]??> + <#if operation.streamingResponse> + ParameterizedTypeReference<${operation.streamingItemType}> localVarReturnType = new ParameterizedTypeReference<${operation.streamingItemType}>() {}; + <#elseif operation.responseObjects[0].contentObjects[0]??> <#if operation.responseObjects[0].contentObjects[0].dataType.baseType == "array" || operation.responseObjects[0].contentObjects[0].dataType.baseType == "map"> ParameterizedTypeReference<${operation.responseObjects[0].contentObjects[0].dataType.innerType}> localVarReturnType = new ParameterizedTypeReference<${operation.responseObjects[0].contentObjects[0].dataType.innerType}>() {}; <#else> @@ -331,14 +337,16 @@ public class ${className?cap_first}Api { return ${operation.operationId}RequestCreation(<@compress single_line=true> <#if operation.parameterObjects?has_content><#list operation.parameterObjects as parameter> ${parameter.variableName}<#if parameter?has_next || operation.requestObjects?has_content>,<#if path.parameterObjects?has_content><#list path.parameterObjects as parameter> ${parameter.variableName}<#if parameter?has_next || operation.requestObjects?has_content>, <#if operation.requestObjects?has_content>${requestArgNames(operation)} - ).<#if operation.responseObjects[0].contentObjects[0]?? && (operation.responseObjects[0].contentObjects[0].dataType.baseType == "array" || operation.responseObjects[0].contentObjects[0].dataType.baseType == "map")>bodyToFlux<#else>bodyToMono(localVarReturnType); + ).<#if operation.streamingResponse || operation.responseObjects[0].contentObjects[0]?? && (operation.responseObjects[0].contentObjects[0].dataType.baseType == "array" || operation.responseObjects[0].contentObjects[0].dataType.baseType == "map")>bodyToFlux<#else>bodyToMono(localVarReturnType); } - public <@compress single_line=true><#if operation.responseObjects[0].contentObjects[0]??> <#if operation.responseObjects[0].contentObjects[0].dataType.baseType == "map"> Flux> <#else> Mono> + public <@compress single_line=true><#if operation.streamingResponse> Mono>><#elseif operation.responseObjects[0].contentObjects[0]??> <#if operation.responseObjects[0].contentObjects[0].dataType.baseType == "map"> Flux> <#else> Mono> <#else> Mono> ${operation.operationId}WithHttpInfo(<@compress single_line=true> <#if operation.parameterObjects?has_content><#list operation.parameterObjects as parameter> ${parameter.dataType} ${parameter.variableName}<#if parameter?has_next || operation.requestObjects?has_content>, <#if path.parameterObjects?has_content><#list path.parameterObjects as parameter> ${parameter.dataType} ${parameter.variableName}<#if parameter?has_next || operation.requestObjects?has_content>, <#if operation.requestObjects?has_content>${requestArgDecls(operation)}) throws WebClientResponseException { - <#if operation.responseObjects[0].contentObjects[0]??> + <#if operation.streamingResponse> + ParameterizedTypeReference<${operation.streamingItemType}> localVarReturnType = new ParameterizedTypeReference<${operation.streamingItemType}>() {}; + <#elseif operation.responseObjects[0].contentObjects[0]??> <#if operation.responseObjects[0].contentObjects[0].dataType.baseType == "array" || operation.responseObjects[0].contentObjects[0].dataType.baseType == "map"> ParameterizedTypeReference<${operation.responseObjects[0].contentObjects[0].dataType.innerType}> localVarReturnType = new ParameterizedTypeReference<${operation.responseObjects[0].contentObjects[0].dataType.innerType}>() {}; <#else> @@ -351,7 +359,7 @@ public class ${className?cap_first}Api { <#if operation.parameterObjects?has_content><#list operation.parameterObjects as parameter> ${parameter.variableName}<#if parameter?has_next || operation.requestObjects?has_content>, <#if path.parameterObjects?has_content><#list path.parameterObjects as parameter> ${parameter.variableName}<#if parameter?has_next || operation.requestObjects?has_content>, <#if operation.requestObjects?has_content>${requestArgNames(operation)} - ).<#if operation.responseObjects[0].contentObjects[0]?? && (operation.responseObjects[0].contentObjects[0].dataType.baseType == "array" || operation.responseObjects[0].contentObjects[0].dataType.baseType == "map")>toEntityList<#else>toEntity(localVarReturnType); + ).<#if operation.streamingResponse>toEntityFlux<#elseif operation.responseObjects[0].contentObjects[0]?? && (operation.responseObjects[0].contentObjects[0].dataType.baseType == "array" || operation.responseObjects[0].contentObjects[0].dataType.baseType == "map")>toEntityList<#else>toEntity(localVarReturnType); } diff --git a/multiapi-engine/src/main/resources/templates/openapi/templateNdjsonSupport.ftlh b/multiapi-engine/src/main/resources/templates/openapi/templateNdjsonSupport.ftlh new file mode 100644 index 00000000..7c7a6419 --- /dev/null +++ b/multiapi-engine/src/main/resources/templates/openapi/templateNdjsonSupport.ftlh @@ -0,0 +1,105 @@ +<#ftl output_format="plainText"> +package <#if packageApi??>${packageApi}<#elseif package??>${package}; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Iterator; +import java.util.List; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import ${javaEEPackage}.servlet.http.HttpServletRequest; +import ${javaEEPackage}.servlet.http.HttpServletResponse; + +import ${jacksonPackage}.core.type.TypeReference; +import ${jacksonPackage}.databind.ObjectMapper; +<#if isJackson3> +import ${jacksonPackage}.databind.json.JsonMapper; + +import org.springframework.context.ApplicationContext; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import org.springframework.web.servlet.support.RequestContextUtils; + +/** + * Sends and reads application/x-ndjson, one JSON document per line, for the API interfaces of this package. Streamed + * responses are written by Spring MVC on its asynchronous executor, each item sent to the client as soon as it is produced, + * and the stream the operation returns is closed once it is sent, also when sending fails. Items are serialized with the + * application's ObjectMapper. + */ +public final class NdjsonSupport { + + private static final MediaType APPLICATION_NDJSON = MediaType.parseMediaType("application/x-ndjson"); + + private NdjsonSupport() { + } + + /** + * The response streaming the items of the given one, with its status and headers. A response without items, such as the + * NOT_IMPLEMENTED of an operation that is not implemented, is sent without a body. + */ + public static ResponseEntity stream(final ResponseEntity> response, final HttpServletRequest servletRequest, + final HttpServletResponse servletResponse) { + final ResponseEntity.BodyBuilder builder = ResponseEntity.status(response.getStatusCode()).headers(response.getHeaders()); + final Stream items = response.getBody(); + if (items == null) { + return builder.build(); + } + final ObjectMapper mapper = objectMapper(servletRequest); + return builder.contentType(APPLICATION_NDJSON).body(output -> write(items, mapper, output, servletResponse)); + } + + /** The response sending the items of the given one as a JSON array, with its status and headers. */ + public static ResponseEntity> collect(final ResponseEntity> response) { + final ResponseEntity.BodyBuilder builder = ResponseEntity.status(response.getStatusCode()).headers(response.getHeaders()); + final Stream items = response.getBody(); + if (items == null) { + return builder.build(); + } + try (Stream closing = items) { + return builder.body(closing.collect(Collectors.toList())); + } + } + + /** The items of the request body, read one line at a time as the stream is consumed. */ + public static Stream read(final HttpServletRequest servletRequest, final TypeReference itemType) throws IOException { + final Iterator items = objectMapper(servletRequest).readerFor(itemType).readValues(servletRequest.getInputStream()); + return StreamSupport.stream(Spliterators.spliteratorUnknownSize(items, Spliterator.ORDERED), false); + } + + /** + * Writes each item and sends it at once. The response is flushed rather than the output stream, whose flush Spring + * Framework 7 ignores by default. + */ + private static void write(final Stream items, final ObjectMapper mapper, final OutputStream output, final HttpServletResponse servletResponse) + throws IOException { + try (Stream closing = items) { + final Iterator iterator = closing.iterator(); + while (iterator.hasNext()) { + output.write(mapper.writeValueAsBytes(iterator.next())); + output.write('\n'); + servletResponse.flushBuffer(); + } + } + } + + /** The application's ObjectMapper, so items are sent as its other responses are, or a default one when it has none. */ + private static ObjectMapper objectMapper(final HttpServletRequest servletRequest) { + final ApplicationContext context = RequestContextUtils.findWebApplicationContext(servletRequest); + ObjectMapper mapper = null; + if (context != null) { + mapper = context.getBeanProvider(ObjectMapper.class).getIfUnique(); + } + if (mapper == null) { +<#if isJackson3> + mapper = JsonMapper.builder().findAndAddModules().build(); +<#else> + mapper = new ObjectMapper().findAndRegisterModules(); + + } + return mapper; + } +} diff --git a/multiapi-engine/src/main/resources/templates/openapi/templateReactive.ftlh b/multiapi-engine/src/main/resources/templates/openapi/templateReactive.ftlh index 811d7f45..e11e7ea6 100644 --- a/multiapi-engine/src/main/resources/templates/openapi/templateReactive.ftlh +++ b/multiapi-engine/src/main/resources/templates/openapi/templateReactive.ftlh @@ -126,29 +126,32 @@ public interface ${className?cap_first}Api { summary = "${operation.summary}", tags = {<#list operation.tags as tag>"${tag}"}<#if operation.responseObjects?has_content>, responses = { -<#list operation.responseObjects as response> @ApiResponse(responseCode = "${response.responseName}", description = "${response.description}"<#if response.contentObjects?has_content>, content = @Content(<#list response.contentObjects as content>mediaType = "${content.name}", schema = @Schema(implementation = <#if content.dataType.baseType == "array" || content.dataType.baseType == "map">${content.dataType.innerType?api.getClassString()}<#else>${content.dataType?api.getClassString()})))<#if !response?is_last>, +<#list operation.responseObjects as response> @ApiResponse(responseCode = "${response.responseName}", description = "${response.description}"<#if response.contentObjects?has_content>, content = <#if (response.contentObjects?size > 1)>{<#list response.contentObjects as content>@Content(mediaType = "${content.name}", schema = @Schema(implementation = <#if content.dataType.baseType == "array" || content.dataType.baseType == "map">${content.dataType.innerType?api.getClassString()}<#else>${content.dataType?api.getClassString()}))<#sep>, <#if (response.contentObjects?size > 1)>})<#if !response?is_last>, } ) @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} + 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]}"} ) <#if operation.securities?has_content> - default Mono<<#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> + 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> <#if operation.parameterObjects?has_content><#list operation.parameterObjects as parameter> @Parameter(name = "${parameter.name}", <#if parameter.description?has_content>description = "${parameter.description!""}", required = ${parameter.required?c}, schema = @Schema(description = "")) <#if parameter.in == "path"> @PathVariable("${parameter.name}") <#elseif parameter.in == "query"> @RequestParam(name = "${parameter.name}", required = ${parameter.required?c}<#if parameter.defaultValue??>, defaultValue = "${parameter.defaultValue?j_string}") <#elseif parameter.in == "header"> @RequestHeader(name = "${parameter.name}", required = ${parameter.required?c}<#if parameter.defaultValue??>, defaultValue = "${parameter.defaultValue?j_string}") <#elseif parameter.in == "cookie"> @CookieValue(name = "${parameter.name}", required = ${parameter.required?c}<#if parameter.defaultValue??>, defaultValue = "${parameter.defaultValue?j_string}") <#if parameter.dateTimeFormatIso??> @DateTimeFormat(iso = DateTimeFormat.ISO.${parameter.dateTimeFormatIso}) ${parameter.dataType} ${parameter.variableName} <#if parameter?has_next || operation.requestObjects?has_content>, <#if path.parameterObjects?has_content><#list path.parameterObjects as parameter> @Parameter(name = "${parameter.name}", <#if parameter.description?has_content>description = "${parameter.description!""}", required = ${parameter.required?c}, schema = @Schema(description = "")) <#if parameter.in == "path"> @PathVariable("${parameter.name}") <#elseif parameter.in == "query"> @RequestParam(name = "${parameter.name}", required = ${parameter.required?c}<#if parameter.defaultValue??>, defaultValue = "${parameter.defaultValue?j_string}") <#elseif parameter.in == "header"> @RequestHeader(name = "${parameter.name}", required = ${parameter.required?c}<#if parameter.defaultValue??>, defaultValue = "${parameter.defaultValue?j_string}") <#elseif parameter.in == "cookie"> @CookieValue(name = "${parameter.name}", required = ${parameter.required?c}<#if parameter.defaultValue??>, defaultValue = "${parameter.defaultValue?j_string}") <#if parameter.dateTimeFormatIso??> @DateTimeFormat(iso = DateTimeFormat.ISO.${parameter.dateTimeFormatIso}) ${parameter.dataType} ${parameter.variableName}<#if parameter?has_next || operation.requestObjects?has_content>, <#if operation.requestObjects?has_content><#list operation.requestObjects as request><#if request.isFormData><@formDataParts request/><#else> @Parameter(name = "${request.contentObjects[0].dataType?api.getVariableNameString()}", description = "${request.description! ""}", required = ${request.required?c}, schema = @Schema(description = "${request.contentObjects[0].description! ""}")) @Valid @RequestBody<#if !request.required>(required = false) - <#if request.contentObjects[0].dataType.baseType == "array" || request.contentObjects[0].dataType.baseType == "map"> Flux<${request.contentObjects[0].dataType.innerType}> + <#if request.streaming> Flux<${request.contentObjects[0].dataType}> + <#elseif request.contentObjects[0].dataType.baseType == "array" || request.contentObjects[0].dataType.baseType == "map"> Flux<${request.contentObjects[0].dataType.innerType}> <#else> Mono<${request.contentObjects[0].dataType}> ${request.contentObjects[0].dataType?api.getVariableNameString()}<#if request?has_next>, <#if operation.parameterObjects?has_content || operation.requestObjects?has_content>, <#if path.parameterObjects?has_content || path.requestObjects?has_content>, @ApiIgnore final ServerWebExchange exchange) { return Mono.just(new ResponseEntity(HttpStatus.NOT_IMPLEMENTED)); } <#else> - default ResponseEntity<@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>Mono<${operation.responseObjects[0].contentObjects[0].dataType}><#else>Void> ${operation.operationId}(<@compress single_line=true> + default ResponseEntity<#if operation.streamingResponse>><#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>Mono<${operation.responseObjects[0].contentObjects[0].dataType}><#else>Void> ${operation.operationId}(<@compress single_line=true> <#if operation.parameterObjects?has_content><#list operation.parameterObjects as parameter> @Parameter(name = "${parameter.name}", <#if parameter.description?has_content>description = "${parameter.description!""}", required = ${parameter.required?c}, schema = @Schema(description = "")) <#if parameter.in == "path"> @PathVariable("${parameter.name}") <#elseif parameter.in == "query"> @RequestParam(name = "${parameter.name}", required = ${parameter.required?c}<#if parameter.defaultValue??>, defaultValue = "${parameter.defaultValue?j_string}") <#elseif parameter.in == "header"> @RequestHeader(name = "${parameter.name}", required = ${parameter.required?c}<#if parameter.defaultValue??>, defaultValue = "${parameter.defaultValue?j_string}") <#elseif parameter.in == "cookie"> @CookieValue(name = "${parameter.name}", required = ${parameter.required?c}<#if parameter.defaultValue??>, defaultValue = "${parameter.defaultValue?j_string}") <#if parameter.dateTimeFormatIso??> @DateTimeFormat(iso = DateTimeFormat.ISO.${parameter.dateTimeFormatIso}) ${parameter.dataType} ${parameter.variableName} <#if parameter?has_next || operation.requestObjects?has_content>, <#if path.parameterObjects?has_content><#list path.parameterObjects as parameter> @Parameter(name = "${parameter.name}", <#if parameter.description?has_content>description = "${parameter.description!""}", required = ${parameter.required?c}, schema = @Schema(description = "")) <#if parameter.in == "path"> @PathVariable("${parameter.name}") <#elseif parameter.in == "query"> @RequestParam(name = "${parameter.name}", required = ${parameter.required?c}<#if parameter.defaultValue??>, defaultValue = "${parameter.defaultValue?j_string}") <#elseif parameter.in == "header"> @RequestHeader(name = "${parameter.name}", required = ${parameter.required?c}<#if parameter.defaultValue??>, defaultValue = "${parameter.defaultValue?j_string}") <#elseif parameter.in == "cookie"> @CookieValue(name = "${parameter.name}", required = ${parameter.required?c}<#if parameter.defaultValue??>, defaultValue = "${parameter.defaultValue?j_string}") <#if parameter.dateTimeFormatIso??> @DateTimeFormat(iso = DateTimeFormat.ISO.${parameter.dateTimeFormatIso}) ${parameter.dataType} ${parameter.variableName}<#if parameter?has_next || operation.requestObjects?has_content>, <#if operation.requestObjects?has_content><#list operation.requestObjects as request><#if request.isFormData><@formDataParts request/><#else> @Parameter(name = "${request.contentObjects[0].dataType?api.getVariableNameString()}", description = "${request.description! ""}", required = ${request.required?c}, schema = @Schema(description = "${request.contentObjects[0].description! ""}")) @Valid @RequestBody<#if !request.required>(required = false) - <#if request.contentObjects[0].dataType.baseType == "array" || request.contentObjects[0].dataType.baseType == "map"> Flux<${request.contentObjects[0].dataType.innerType}> + <#if request.streaming> Flux<${request.contentObjects[0].dataType}> + <#elseif request.contentObjects[0].dataType.baseType == "array" || request.contentObjects[0].dataType.baseType == "map"> Flux<${request.contentObjects[0].dataType.innerType}> <#else> Mono<${request.contentObjects[0].dataType}> ${request.contentObjects[0].dataType?api.getVariableNameString()}<#if request?has_next>, <#if operation.parameterObjects?has_content || operation.requestObjects?has_content>, <#if path.parameterObjects?has_content || path.requestObjects?has_content>, @ApiIgnore final ServerWebExchange exchange) { return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); } diff --git a/multiapi-engine/src/main/resources/templates/openapi/templateWebClient.ftlh b/multiapi-engine/src/main/resources/templates/openapi/templateWebClient.ftlh index 53537be7..99ca6f53 100644 --- a/multiapi-engine/src/main/resources/templates/openapi/templateWebClient.ftlh +++ b/multiapi-engine/src/main/resources/templates/openapi/templateWebClient.ftlh @@ -47,6 +47,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.client.RestClientException; import org.springframework.web.reactive.function.BodyInserter; import org.springframework.web.reactive.function.BodyInserters; +import org.reactivestreams.Publisher; import org.springframework.web.reactive.function.client.ExchangeStrategies; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient.ResponseSpec; @@ -157,15 +158,17 @@ public class ApiWebClient { } private static WebClient buildWebClient(final ${mapperType} mapper) { + // application/x-ndjson is read and written with the same mapper, one item per line. + final MediaType ndjson = new MediaType("application", "x-ndjson"); ExchangeStrategies strategies = ExchangeStrategies .builder() .codecs(clientDefaultCodecsConfigurer -> { <#if isJackson3> - clientDefaultCodecsConfigurer.defaultCodecs().jacksonJsonEncoder(new JacksonJsonEncoder(mapper, MediaType.APPLICATION_JSON)); - clientDefaultCodecsConfigurer.defaultCodecs().jacksonJsonDecoder(new JacksonJsonDecoder(mapper, MediaType.APPLICATION_JSON)); + clientDefaultCodecsConfigurer.defaultCodecs().jacksonJsonEncoder(new JacksonJsonEncoder(mapper, MediaType.APPLICATION_JSON, ndjson)); + clientDefaultCodecsConfigurer.defaultCodecs().jacksonJsonDecoder(new JacksonJsonDecoder(mapper, MediaType.APPLICATION_JSON, ndjson)); <#else> - clientDefaultCodecsConfigurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(mapper, MediaType.APPLICATION_JSON)); - clientDefaultCodecsConfigurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(mapper, MediaType.APPLICATION_JSON)); + clientDefaultCodecsConfigurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(mapper, MediaType.APPLICATION_JSON, ndjson)); + clientDefaultCodecsConfigurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(mapper, MediaType.APPLICATION_JSON, ndjson)); }).build(); WebClient.Builder webClientBuilder = WebClient.builder().exchangeStrategies(strategies); @@ -357,6 +360,11 @@ public class ApiWebClient { return BodyInserters.fromFormData(map); } else if(MediaType.MULTIPART_FORM_DATA.equals(contentType)) { return BodyInserters.fromMultipartData(formParams); + } else if (obj instanceof Publisher) { + // A streamed body, such as application/x-ndjson, is sent item by item as it is published. + @SuppressWarnings("unchecked") + final Publisher items = (Publisher) obj; + return BodyInserters.fromPublisher(items, Object.class); } else { return obj != null ? BodyInserters.fromValue(obj) : null; } 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 e2c55367..9e78675a 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 @@ -313,6 +313,60 @@ public final class OpenApiGeneratorFixtures { .clientPackage("com.sngular.multifileplugin.requestbodyrequiredwebclient.client") .modelNameSuffix("DTO").useTagsGroup(true).callMode(true).isReactive(true).build()); + static final List TEST_NDJSON_STREAMING = List + .of(SpecFile.builder().filePath("openapigenerator/testNdjsonStreaming/api-test.yml") + .apiPackage("com.sngular.multifileplugin.ndjsonstreaming") + .modelPackage("com.sngular.multifileplugin.ndjsonstreaming.model") + .clientPackage("com.sngular.multifileplugin.ndjsonstreaming.client") + .modelNameSuffix("DTO").useTagsGroup(true).build()); + + static final List TEST_NDJSON_STREAMING_REACTIVE = List + .of(SpecFile.builder().filePath("openapigenerator/testNdjsonStreaming/api-test.yml") + .apiPackage("com.sngular.multifileplugin.ndjsonstreamingreactive") + .modelPackage("com.sngular.multifileplugin.ndjsonstreamingreactive.model") + .clientPackage("com.sngular.multifileplugin.ndjsonstreamingreactive.client") + .modelNameSuffix("DTO").useTagsGroup(true).isReactive(true).build()); + + static final List TEST_NDJSON_STREAMING_WEB_CLIENT = List + .of(SpecFile.builder().filePath("openapigenerator/testNdjsonStreaming/api-test.yml") + .apiPackage("com.sngular.multifileplugin.ndjsonstreamingwebclient") + .modelPackage("com.sngular.multifileplugin.ndjsonstreamingwebclient.model") + .clientPackage("com.sngular.multifileplugin.ndjsonstreamingwebclient.client") + .modelNameSuffix("DTO").useTagsGroup(true).callMode(true).isReactive(true).build()); + + static final List TEST_NDJSON_STREAMING_HTTP_EXCHANGE = List + .of(SpecFile.builder().filePath("openapigenerator/testNdjsonStreaming/api-test.yml") + .apiPackage("com.sngular.multifileplugin.ndjsonstreaminghttpexchange") + .modelPackage("com.sngular.multifileplugin.ndjsonstreaminghttpexchange.model") + .clientPackage("com.sngular.multifileplugin.ndjsonstreaminghttpexchange.client") + .modelNameSuffix("DTO").useTagsGroup(true).callMode(true).useHttpExchange(true).isReactive(true).build()); + + static final List TEST_NDJSON_STREAMING_REST_CLIENT = List + .of(SpecFile.builder().filePath("openapigenerator/testNdjsonStreaming/api-test.yml") + .apiPackage("com.sngular.multifileplugin.ndjsonstreamingrestclient") + .modelPackage("com.sngular.multifileplugin.ndjsonstreamingrestclient.model") + .clientPackage("com.sngular.multifileplugin.ndjsonstreamingrestclient.client") + .modelNameSuffix("DTO").useTagsGroup(true).callMode(true).build()); + + static final List TEST_NDJSON_STREAMING_HTTP_EXCHANGE_BLOCKING = List + .of(SpecFile.builder().filePath("openapigenerator/testNdjsonStreaming/api-test.yml") + .apiPackage("com.sngular.multifileplugin.ndjsonstreaminghttpexchangeblocking") + .modelPackage("com.sngular.multifileplugin.ndjsonstreaminghttpexchangeblocking.model") + .clientPackage("com.sngular.multifileplugin.ndjsonstreaminghttpexchangeblocking.client") + .modelNameSuffix("DTO").useTagsGroup(true).callMode(true).useHttpExchange(true).build()); + + static final List TEST_NDJSON_STREAMING_JSON_MISMATCH = List + .of(SpecFile.builder().filePath("openapigenerator/testNdjsonStreaming/api-json-mismatch.yml") + .apiPackage("com.sngular.multifileplugin.ndjsonjsonmismatch") + .modelPackage("com.sngular.multifileplugin.ndjsonjsonmismatch.model") + .modelNameSuffix("DTO").useTagsGroup(true).build()); + + static final List TEST_NDJSON_STREAMING_MIXED_REQUEST = List + .of(SpecFile.builder().filePath("openapigenerator/testNdjsonStreaming/api-mixed-request.yml") + .apiPackage("com.sngular.multifileplugin.ndjsonmixedrequest") + .modelPackage("com.sngular.multifileplugin.ndjsonmixedrequest.model") + .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") @@ -2092,6 +2146,17 @@ static Function validateRequestBodyRequired(final String packageF DEFAULT_TARGET_API + "/model", Collections.emptyList(), null); } + static Function validateNdjsonStreaming(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/testNdjsonStreaming/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 94f739b4..1a8c6a9f 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 @@ -39,6 +39,9 @@ static Stream fileSpecToProcess() { return Stream.of( Arguments.of("testApiReactiveGeneration", OpenApiGeneratorFixtures.TEST_API_REACTIVE_GENERATION, OpenApiGeneratorFixtures.validateApiReactiveGeneration(SPRING_BOOT_VERSION)), + 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("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 075cb70b..e8862798 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 @@ -12,9 +12,11 @@ import java.util.function.Function; import java.util.stream.Stream; +import com.sngular.api.generator.plugin.openapi.exception.CodeGenerationException; import com.sngular.api.generator.plugin.openapi.parameter.SpecFile; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.CleanupMode; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; @@ -54,11 +56,25 @@ static Stream fileSpecToProcess() { OpenApiGeneratorFixtures.validateRequestBodyRequired("requestbodyrequiredhttpexchange", "httpexchange")), Arguments.of("testHttpExchangeClientReactive", OpenApiGeneratorFixtures.TEST_HTTP_EXCHANGE_CLIENT_REACTIVE, OpenApiGeneratorFixtures.validateHttpExchangeClient("httpexchangereactive", "reactive")), + Arguments.of("testNdjsonStreaming_imperative", OpenApiGeneratorFixtures.TEST_NDJSON_STREAMING, + OpenApiGeneratorFixtures.validateNdjsonStreaming("ndjsonstreaming", "imperative-boot4", + List.of("EventsApi.java", "ItemsApi.java", "NdjsonSupport.java"), List.of())), + Arguments.of("testNdjsonStreaming_httpexchange", OpenApiGeneratorFixtures.TEST_NDJSON_STREAMING_HTTP_EXCHANGE, + OpenApiGeneratorFixtures.validateNdjsonStreaming("ndjsonstreaminghttpexchange", "httpexchange", + List.of("EventsApi.java", "ItemsApi.java"), List.of())), Arguments.of("testQueryObjectsHttpExchange", OpenApiGeneratorFixtures.TEST_QUERY_OBJECTS_HTTP_EXCHANGE, OpenApiGeneratorFixtures.validateQueryObjectsHttpExchange()) ); } + @Test + void testNdjsonStreamingNeedsReactiveHttpExchange() { + // A blocking @HttpExchange interface is backed by RestClient, which reads a response whole. + Assertions.assertThatThrownBy(() -> openApiGenerator.processFileSpec(OpenApiGeneratorFixtures.TEST_NDJSON_STREAMING_HTTP_EXCHANGE_BLOCKING)) + .isInstanceOf(CodeGenerationException.class) + .hasMessageContaining("reactive=true"); + } + @ParameterizedTest(name = "Test {index} - Spring Boot 4 File Spec for case {0}") @MethodSource("fileSpecToProcess") void processFileSpec(final String type, final List specFileList, final Function validation) { 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 971effd5..1c973178 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 @@ -133,6 +133,16 @@ static Stream fileSpecToProcess() { OpenApiGeneratorFixtures.validateExternalComponentSchemaRefs()), Arguments.of("testInlineSchemaNameClashes", OpenApiGeneratorFixtures.TEST_INLINE_SCHEMA_NAME_CLASHES, OpenApiGeneratorFixtures.validateInlineSchemaNameClashes()), + Arguments.of("testNdjsonStreaming_imperative", OpenApiGeneratorFixtures.TEST_NDJSON_STREAMING, + OpenApiGeneratorFixtures.validateNdjsonStreaming("ndjsonstreaming", "imperative", + List.of("EventsApi.java", "ItemsApi.java", "NdjsonSupport.java"), + List.of("ErrorDTO.java", "ImportResultDTO.java", "InlineResponse200StreamEventsDTO.java", "ItemDTO.java"))), + Arguments.of("testNdjsonStreaming_reactive", OpenApiGeneratorFixtures.TEST_NDJSON_STREAMING_REACTIVE, + OpenApiGeneratorFixtures.validateNdjsonStreaming("ndjsonstreamingreactive", "reactive", + List.of("EventsApi.java", "ItemsApi.java"), List.of())), + Arguments.of("testNdjsonStreaming_webclient", OpenApiGeneratorFixtures.TEST_NDJSON_STREAMING_WEB_CLIENT, + OpenApiGeneratorFixtures.validateNdjsonStreaming("ndjsonstreamingwebclient", "webclient", + List.of("EventsApi.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, @@ -248,6 +258,31 @@ void testExceptionForTestGenerationWithNoOperationId() { .isInstanceOf(InvalidAPIException.class); } + @Test + void testNdjsonStreamingNeedsReactiveClient() { + // RestTemplate and RestClient read a response whole, so they cannot hand the items over as they are streamed. + Assertions.assertThatThrownBy(() -> openApiGenerator.processFileSpec(OpenApiGeneratorFixtures.TEST_NDJSON_STREAMING_REST_CLIENT)) + .isInstanceOf(CodeGenerationException.class) + .hasMessageContaining("listItems") + .hasMessageContaining("reactive=true"); + } + + @Test + void testNdjsonStreamingJsonAlternativeMustListTheItems() { + Assertions.assertThatThrownBy(() -> openApiGenerator.processFileSpec(OpenApiGeneratorFixtures.TEST_NDJSON_STREAMING_JSON_MISMATCH)) + .isInstanceOf(CodeGenerationException.class) + .hasMessageContaining("listItems") + .hasMessageContaining("must be the array of them"); + } + + @Test + void testNdjsonStreamedBodyMustBeItsOnlyContent() { + Assertions.assertThatThrownBy(() -> openApiGenerator.processFileSpec(OpenApiGeneratorFixtures.TEST_NDJSON_STREAMING_MIXED_REQUEST)) + .isInstanceOf(CodeGenerationException.class) + .hasMessageContaining("importItems") + .hasMessageContaining("only the streamed media type"); + } + @Test void testHttpExchangeClientNeedsSpringBoot3() { // @HttpExchange is part of Spring Framework 6, so interfaces generated for Spring Boot 2 would not compile. diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/api-json-mismatch.yml b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/api-json-mismatch.yml new file mode 100644 index 00000000..5674e62e --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/api-json-mismatch.yml @@ -0,0 +1,33 @@ +openapi: "3.0.3" +info: + title: NDJSON items that the JSON alternative does not list + version: 1.0.0 +paths: + /items: + get: + tags: [items] + operationId: listItems + responses: + '200': + description: A page of items, or the items one per line + content: + application/json: + schema: + $ref: "#/components/schemas/ItemPage" + application/x-ndjson: + schema: + $ref: "#/components/schemas/Item" +components: + schemas: + Item: + type: object + properties: + id: + type: string + ItemPage: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/Item" diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/api-mixed-request.yml b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/api-mixed-request.yml new file mode 100644 index 00000000..c0122d54 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/api-mixed-request.yml @@ -0,0 +1,29 @@ +openapi: "3.0.3" +info: + title: A body sent either streamed or as JSON + version: 1.0.0 +paths: + /items: + post: + tags: [items] + operationId: importItems + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Item" + application/x-ndjson: + schema: + $ref: "#/components/schemas/Item" + responses: + '204': + description: Imported +components: + schemas: + Item: + type: object + properties: + id: + type: string diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/api-test.yml b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/api-test.yml new file mode 100644 index 00000000..2d2f365a --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/api-test.yml @@ -0,0 +1,116 @@ +openapi: "3.2.0" +info: + title: NDJSON streaming + version: 1.0.0 +paths: + /items: + get: + tags: [items] + operationId: listItems + parameters: + - name: filter + in: query + required: false + schema: + type: string + responses: + '200': + description: One item per line + content: + application/x-ndjson: + schema: + $ref: "#/components/schemas/Item" + '400': + description: The filter is not valid + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /items/export: + get: + tags: [items] + operationId: exportItems + responses: + '200': + description: The items, as a JSON array or one per line + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Item" + application/x-ndjson: + schema: + type: array + items: + $ref: "#/components/schemas/Item" + /items/import: + post: + tags: [items] + operationId: importItems + requestBody: + required: true + content: + application/x-ndjson: + schema: + $ref: "#/components/schemas/Item" + responses: + '200': + description: Import summary + content: + application/json: + schema: + $ref: "#/components/schemas/ImportResult" + /items/{itemId}: + get: + tags: [items] + operationId: getItem + parameters: + - name: itemId + in: path + required: true + schema: + type: string + responses: + '200': + description: The item + content: + application/json: + schema: + $ref: "#/components/schemas/Item" + /events: + get: + tags: [events] + operationId: streamEvents + responses: + '200': + description: One event per line + content: + application/x-ndjson: + itemSchema: + type: object + properties: + id: + type: string + type: + type: string +components: + schemas: + Item: + type: object + properties: + id: + type: string + name: + type: string + Error: + type: object + properties: + message: + type: string + ImportResult: + type: object + properties: + imported: + type: integer + format: int32 diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/httpexchange/EventsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/httpexchange/EventsApi.java new file mode 100644 index 00000000..bfc47c77 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/httpexchange/EventsApi.java @@ -0,0 +1,41 @@ +package com.sngular.multifileplugin.ndjsonstreaminghttpexchange; + +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 reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import com.sngular.multifileplugin.ndjsonstreaminghttpexchange.model.InlineResponse200StreamEventsDTO; + +/** + * Events API, as a Spring HTTP service interface. Back it with a configured {@code WebClient}, whose + * base URL, authentication, timeouts and message converters apply to every request: + *
+ * HttpServiceProxyFactory.builderFor(WebClientAdapter.create(webClient)).build()
+ *     .createClient(EventsApi.class);
+ * 
+ * or, on Spring Boot 4, register it with {@code @ImportHttpServices}. + */ +@HttpExchange +public interface EventsApi { + + /** + * GET /events + * @return One event per line (status code 200); + */ + @GetExchange(url = "/events", accept = {"application/x-ndjson"}) + Mono>> streamEvents(); +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/httpexchange/ItemsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/httpexchange/ItemsApi.java new file mode 100644 index 00000000..d2917d5e --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/httpexchange/ItemsApi.java @@ -0,0 +1,67 @@ +package com.sngular.multifileplugin.ndjsonstreaminghttpexchange; + +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 reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import com.sngular.multifileplugin.ndjsonstreaminghttpexchange.model.ItemDTO; +import com.sngular.multifileplugin.ndjsonstreaminghttpexchange.model.ErrorDTO; +import com.sngular.multifileplugin.ndjsonstreaminghttpexchange.model.ImportResultDTO; + +/** + * Items API, as a Spring HTTP service interface. Back it with a configured {@code WebClient}, whose + * base URL, authentication, timeouts and message converters apply to every request: + *
+ * HttpServiceProxyFactory.builderFor(WebClientAdapter.create(webClient)).build()
+ *     .createClient(ItemsApi.class);
+ * 
+ * or, on Spring Boot 4, register it with {@code @ImportHttpServices}. + */ +@HttpExchange +public interface ItemsApi { + + /** + * GET /items + * @param filter + * @return One item per line (status code 200); The filter is not valid (status code 400); + */ + @GetExchange(url = "/items", accept = {"application/x-ndjson"}) + Mono>> listItems(@RequestParam(name = "filter", required = false) String filter); + + /** + * GET /items/export + * @return The items, as a JSON array or one per line (status code 200); + */ + @GetExchange(url = "/items/export", accept = {"application/x-ndjson"}) + Mono>> exportItems(); + + /** + * POST /items/import + * @param itemDTO (required) + * @return Import summary (status code 200); + */ + @PostExchange(url = "/items/import", accept = {"application/json"}, contentType = "application/x-ndjson") + Mono> importItems(@RequestBody(required = true) Flux itemDTO); + + /** + * GET /items/{itemId} + * @param itemId (required) + * @return The item (status code 200); + */ + @GetExchange(url = "/items/{itemId}", accept = {"application/json"}) + Mono> getItem(@PathVariable("itemId") String itemId); +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot3/EventsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot3/EventsApi.java new file mode 100644 index 00000000..34e21974 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot3/EventsApi.java @@ -0,0 +1,54 @@ +package com.sngular.multifileplugin.ndjsonstreaming; + +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 java.util.stream.Stream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import com.sngular.multifileplugin.ndjsonstreaming.model.InlineResponse200StreamEventsDTO; + +public interface EventsApi { + + /** + * GET /events, sending the items {@link #streamEvents} returns as application/x-ndjson, one JSON document per line. + */ + @Operation( + operationId = "streamEvents", + tags = {"events"}, + responses = { + @ApiResponse(responseCode = "200", description = "One event per line", content = @Content(mediaType = "application/x-ndjson", schema = @Schema(implementation = InlineResponse200StreamEventsDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/events", + produces = {"application/x-ndjson"} + ) + default ResponseEntity streamEventsNdjson(@Parameter(hidden = true) final HttpServletRequest servletRequest, @Parameter(hidden = true) final HttpServletResponse servletResponse) { + return NdjsonSupport.stream(streamEvents(), servletRequest, servletResponse); + } + + /** + * GET /events. Implement this method rather than the endpoint above: the items are sent as they are produced, and the stream is closed once they are sent. + */ + default ResponseEntity> streamEvents() { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot3/ItemsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot3/ItemsApi.java new file mode 100644 index 00000000..eeba0b35 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot3/ItemsApi.java @@ -0,0 +1,153 @@ +package com.sngular.multifileplugin.ndjsonstreaming; + +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 java.util.stream.Stream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import com.fasterxml.jackson.core.type.TypeReference; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import com.sngular.multifileplugin.ndjsonstreaming.model.ItemDTO; +import com.sngular.multifileplugin.ndjsonstreaming.model.ErrorDTO; +import com.sngular.multifileplugin.ndjsonstreaming.model.ImportResultDTO; + +public interface ItemsApi { + + /** + * GET /items, sending the items {@link #listItems} returns as application/x-ndjson, one JSON document per line. + */ + @Operation( + operationId = "listItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "One item per line", content = @Content(mediaType = "application/x-ndjson", schema = @Schema(implementation = ItemDTO.class))), + @ApiResponse(responseCode = "400", description = "The filter is not valid", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items", + produces = {"application/x-ndjson"} + ) + default ResponseEntity listItemsNdjson(@Parameter(name = "filter", required = false, schema = @Schema(description = "")) @RequestParam(name = "filter", required = false) String filter, @Parameter(hidden = true) final HttpServletRequest servletRequest, @Parameter(hidden = true) final HttpServletResponse servletResponse) { + return NdjsonSupport.stream(listItems(filter), servletRequest, servletResponse); + } + + /** + * GET /items. Implement this method rather than the endpoint above: the items are sent as they are produced, and the stream is closed once they are sent. + */ + default ResponseEntity> listItems(final String filter) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * GET /items/export, sending the items {@link #exportItems} returns as application/x-ndjson, one JSON document per line. + */ + @Operation( + operationId = "exportItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The items, as a JSON array or one per line", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = List.class)), @Content(mediaType = "application/x-ndjson", schema = @Schema(implementation = ItemDTO.class))}) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items/export", + produces = {"application/x-ndjson"} + ) + default ResponseEntity exportItemsNdjson(@Parameter(hidden = true) final HttpServletRequest servletRequest, @Parameter(hidden = true) final HttpServletResponse servletResponse) { + return NdjsonSupport.stream(exportItems(), servletRequest, servletResponse); + } + + /** + * GET /items/export, sending the items {@link #exportItems} returns as a JSON array. + */ + @Operation( + operationId = "exportItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The items, as a JSON array or one per line", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = List.class)), @Content(mediaType = "application/x-ndjson", schema = @Schema(implementation = ItemDTO.class))}) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items/export", + produces = {"application/json"} + ) + default ResponseEntity> exportItemsJson() { + return NdjsonSupport.collect(exportItems()); + } + + /** + * GET /items/export. Implement this method rather than the endpoints above: the items are sent as they are produced, and the stream is closed once they are sent. + */ + default ResponseEntity> exportItems() { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * POST /items/import, reading the application/x-ndjson body, one JSON document per line, as the items {@link #importItems} receives. + */ + @Operation( + operationId = "importItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "Import summary", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ImportResultDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.POST, + value = "/items/import", + produces = {"application/json"}, + consumes = {"application/x-ndjson"} + ) + default ResponseEntity importItemsNdjson(@Parameter(hidden = true) final HttpServletRequest servletRequest) throws IOException { + return importItems(NdjsonSupport.read(servletRequest, new TypeReference() {})); + } + + /** + * POST /items/import. Implement this method rather than the endpoint above: the body items are read as the stream is consumed. + */ + default ResponseEntity importItems(final Stream itemDTO) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * GET /items/{itemId} + * @param itemId true + * @return The item; (status code 200) + */ + + @Operation( + operationId = "getItem", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The item", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ItemDTO.class))) + } + ) + @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); + } + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot3/NdjsonSupport.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot3/NdjsonSupport.java new file mode 100644 index 00000000..09676305 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot3/NdjsonSupport.java @@ -0,0 +1,97 @@ +package com.sngular.multifileplugin.ndjsonstreaming; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Iterator; +import java.util.List; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.ApplicationContext; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import org.springframework.web.servlet.support.RequestContextUtils; + +/** + * Sends and reads application/x-ndjson, one JSON document per line, for the API interfaces of this package. Streamed + * responses are written by Spring MVC on its asynchronous executor, each item sent to the client as soon as it is produced, + * and the stream the operation returns is closed once it is sent, also when sending fails. Items are serialized with the + * application's ObjectMapper. + */ +public final class NdjsonSupport { + + private static final MediaType APPLICATION_NDJSON = MediaType.parseMediaType("application/x-ndjson"); + + private NdjsonSupport() { + } + + /** + * The response streaming the items of the given one, with its status and headers. A response without items, such as the + * NOT_IMPLEMENTED of an operation that is not implemented, is sent without a body. + */ + public static ResponseEntity stream(final ResponseEntity> response, final HttpServletRequest servletRequest, + final HttpServletResponse servletResponse) { + final ResponseEntity.BodyBuilder builder = ResponseEntity.status(response.getStatusCode()).headers(response.getHeaders()); + final Stream items = response.getBody(); + if (items == null) { + return builder.build(); + } + final ObjectMapper mapper = objectMapper(servletRequest); + return builder.contentType(APPLICATION_NDJSON).body(output -> write(items, mapper, output, servletResponse)); + } + + /** The response sending the items of the given one as a JSON array, with its status and headers. */ + public static ResponseEntity> collect(final ResponseEntity> response) { + final ResponseEntity.BodyBuilder builder = ResponseEntity.status(response.getStatusCode()).headers(response.getHeaders()); + final Stream items = response.getBody(); + if (items == null) { + return builder.build(); + } + try (Stream closing = items) { + return builder.body(closing.collect(Collectors.toList())); + } + } + + /** The items of the request body, read one line at a time as the stream is consumed. */ + public static Stream read(final HttpServletRequest servletRequest, final TypeReference itemType) throws IOException { + final Iterator items = objectMapper(servletRequest).readerFor(itemType).readValues(servletRequest.getInputStream()); + return StreamSupport.stream(Spliterators.spliteratorUnknownSize(items, Spliterator.ORDERED), false); + } + + /** + * Writes each item and sends it at once. The response is flushed rather than the output stream, whose flush Spring + * Framework 7 ignores by default. + */ + private static void write(final Stream items, final ObjectMapper mapper, final OutputStream output, final HttpServletResponse servletResponse) + throws IOException { + try (Stream closing = items) { + final Iterator iterator = closing.iterator(); + while (iterator.hasNext()) { + output.write(mapper.writeValueAsBytes(iterator.next())); + output.write('\n'); + servletResponse.flushBuffer(); + } + } + } + + /** The application's ObjectMapper, so items are sent as its other responses are, or a default one when it has none. */ + private static ObjectMapper objectMapper(final HttpServletRequest servletRequest) { + final ApplicationContext context = RequestContextUtils.findWebApplicationContext(servletRequest); + ObjectMapper mapper = null; + if (context != null) { + mapper = context.getBeanProvider(ObjectMapper.class).getIfUnique(); + } + if (mapper == null) { + mapper = new ObjectMapper().findAndRegisterModules(); + } + return mapper; + } +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot4/EventsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot4/EventsApi.java new file mode 100644 index 00000000..34e21974 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot4/EventsApi.java @@ -0,0 +1,54 @@ +package com.sngular.multifileplugin.ndjsonstreaming; + +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 java.util.stream.Stream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import com.sngular.multifileplugin.ndjsonstreaming.model.InlineResponse200StreamEventsDTO; + +public interface EventsApi { + + /** + * GET /events, sending the items {@link #streamEvents} returns as application/x-ndjson, one JSON document per line. + */ + @Operation( + operationId = "streamEvents", + tags = {"events"}, + responses = { + @ApiResponse(responseCode = "200", description = "One event per line", content = @Content(mediaType = "application/x-ndjson", schema = @Schema(implementation = InlineResponse200StreamEventsDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/events", + produces = {"application/x-ndjson"} + ) + default ResponseEntity streamEventsNdjson(@Parameter(hidden = true) final HttpServletRequest servletRequest, @Parameter(hidden = true) final HttpServletResponse servletResponse) { + return NdjsonSupport.stream(streamEvents(), servletRequest, servletResponse); + } + + /** + * GET /events. Implement this method rather than the endpoint above: the items are sent as they are produced, and the stream is closed once they are sent. + */ + default ResponseEntity> streamEvents() { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot4/ItemsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot4/ItemsApi.java new file mode 100644 index 00000000..535bed4b --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot4/ItemsApi.java @@ -0,0 +1,153 @@ +package com.sngular.multifileplugin.ndjsonstreaming; + +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 java.util.stream.Stream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import tools.jackson.core.type.TypeReference; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import com.sngular.multifileplugin.ndjsonstreaming.model.ItemDTO; +import com.sngular.multifileplugin.ndjsonstreaming.model.ErrorDTO; +import com.sngular.multifileplugin.ndjsonstreaming.model.ImportResultDTO; + +public interface ItemsApi { + + /** + * GET /items, sending the items {@link #listItems} returns as application/x-ndjson, one JSON document per line. + */ + @Operation( + operationId = "listItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "One item per line", content = @Content(mediaType = "application/x-ndjson", schema = @Schema(implementation = ItemDTO.class))), + @ApiResponse(responseCode = "400", description = "The filter is not valid", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items", + produces = {"application/x-ndjson"} + ) + default ResponseEntity listItemsNdjson(@Parameter(name = "filter", required = false, schema = @Schema(description = "")) @RequestParam(name = "filter", required = false) String filter, @Parameter(hidden = true) final HttpServletRequest servletRequest, @Parameter(hidden = true) final HttpServletResponse servletResponse) { + return NdjsonSupport.stream(listItems(filter), servletRequest, servletResponse); + } + + /** + * GET /items. Implement this method rather than the endpoint above: the items are sent as they are produced, and the stream is closed once they are sent. + */ + default ResponseEntity> listItems(final String filter) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * GET /items/export, sending the items {@link #exportItems} returns as application/x-ndjson, one JSON document per line. + */ + @Operation( + operationId = "exportItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The items, as a JSON array or one per line", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = List.class)), @Content(mediaType = "application/x-ndjson", schema = @Schema(implementation = ItemDTO.class))}) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items/export", + produces = {"application/x-ndjson"} + ) + default ResponseEntity exportItemsNdjson(@Parameter(hidden = true) final HttpServletRequest servletRequest, @Parameter(hidden = true) final HttpServletResponse servletResponse) { + return NdjsonSupport.stream(exportItems(), servletRequest, servletResponse); + } + + /** + * GET /items/export, sending the items {@link #exportItems} returns as a JSON array. + */ + @Operation( + operationId = "exportItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The items, as a JSON array or one per line", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = List.class)), @Content(mediaType = "application/x-ndjson", schema = @Schema(implementation = ItemDTO.class))}) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items/export", + produces = {"application/json"} + ) + default ResponseEntity> exportItemsJson() { + return NdjsonSupport.collect(exportItems()); + } + + /** + * GET /items/export. Implement this method rather than the endpoints above: the items are sent as they are produced, and the stream is closed once they are sent. + */ + default ResponseEntity> exportItems() { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * POST /items/import, reading the application/x-ndjson body, one JSON document per line, as the items {@link #importItems} receives. + */ + @Operation( + operationId = "importItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "Import summary", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ImportResultDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.POST, + value = "/items/import", + produces = {"application/json"}, + consumes = {"application/x-ndjson"} + ) + default ResponseEntity importItemsNdjson(@Parameter(hidden = true) final HttpServletRequest servletRequest) throws IOException { + return importItems(NdjsonSupport.read(servletRequest, new TypeReference() {})); + } + + /** + * POST /items/import. Implement this method rather than the endpoint above: the body items are read as the stream is consumed. + */ + default ResponseEntity importItems(final Stream itemDTO) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * GET /items/{itemId} + * @param itemId true + * @return The item; (status code 200) + */ + + @Operation( + operationId = "getItem", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The item", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ItemDTO.class))) + } + ) + @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); + } + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot4/NdjsonSupport.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot4/NdjsonSupport.java new file mode 100644 index 00000000..0ae8f5d3 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot4/NdjsonSupport.java @@ -0,0 +1,98 @@ +package com.sngular.multifileplugin.ndjsonstreaming; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Iterator; +import java.util.List; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import org.springframework.context.ApplicationContext; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import org.springframework.web.servlet.support.RequestContextUtils; + +/** + * Sends and reads application/x-ndjson, one JSON document per line, for the API interfaces of this package. Streamed + * responses are written by Spring MVC on its asynchronous executor, each item sent to the client as soon as it is produced, + * and the stream the operation returns is closed once it is sent, also when sending fails. Items are serialized with the + * application's ObjectMapper. + */ +public final class NdjsonSupport { + + private static final MediaType APPLICATION_NDJSON = MediaType.parseMediaType("application/x-ndjson"); + + private NdjsonSupport() { + } + + /** + * The response streaming the items of the given one, with its status and headers. A response without items, such as the + * NOT_IMPLEMENTED of an operation that is not implemented, is sent without a body. + */ + public static ResponseEntity stream(final ResponseEntity> response, final HttpServletRequest servletRequest, + final HttpServletResponse servletResponse) { + final ResponseEntity.BodyBuilder builder = ResponseEntity.status(response.getStatusCode()).headers(response.getHeaders()); + final Stream items = response.getBody(); + if (items == null) { + return builder.build(); + } + final ObjectMapper mapper = objectMapper(servletRequest); + return builder.contentType(APPLICATION_NDJSON).body(output -> write(items, mapper, output, servletResponse)); + } + + /** The response sending the items of the given one as a JSON array, with its status and headers. */ + public static ResponseEntity> collect(final ResponseEntity> response) { + final ResponseEntity.BodyBuilder builder = ResponseEntity.status(response.getStatusCode()).headers(response.getHeaders()); + final Stream items = response.getBody(); + if (items == null) { + return builder.build(); + } + try (Stream closing = items) { + return builder.body(closing.collect(Collectors.toList())); + } + } + + /** The items of the request body, read one line at a time as the stream is consumed. */ + public static Stream read(final HttpServletRequest servletRequest, final TypeReference itemType) throws IOException { + final Iterator items = objectMapper(servletRequest).readerFor(itemType).readValues(servletRequest.getInputStream()); + return StreamSupport.stream(Spliterators.spliteratorUnknownSize(items, Spliterator.ORDERED), false); + } + + /** + * Writes each item and sends it at once. The response is flushed rather than the output stream, whose flush Spring + * Framework 7 ignores by default. + */ + private static void write(final Stream items, final ObjectMapper mapper, final OutputStream output, final HttpServletResponse servletResponse) + throws IOException { + try (Stream closing = items) { + final Iterator iterator = closing.iterator(); + while (iterator.hasNext()) { + output.write(mapper.writeValueAsBytes(iterator.next())); + output.write('\n'); + servletResponse.flushBuffer(); + } + } + } + + /** The application's ObjectMapper, so items are sent as its other responses are, or a default one when it has none. */ + private static ObjectMapper objectMapper(final HttpServletRequest servletRequest) { + final ApplicationContext context = RequestContextUtils.findWebApplicationContext(servletRequest); + ObjectMapper mapper = null; + if (context != null) { + mapper = context.getBeanProvider(ObjectMapper.class).getIfUnique(); + } + if (mapper == null) { + mapper = JsonMapper.builder().findAndAddModules().build(); + } + return mapper; + } +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/ErrorDTO.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/ErrorDTO.java new file mode 100644 index 00000000..df758493 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/ErrorDTO.java @@ -0,0 +1,76 @@ +package com.sngular.multifileplugin.ndjsonstreaming.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 = ErrorDTO.ErrorDTOBuilder.class) +public class ErrorDTO { + + @JsonProperty(value ="message") + private String message; + + private ErrorDTO(ErrorDTOBuilder builder) { + this.message = builder.message; + + } + + public static ErrorDTO.ErrorDTOBuilder builder() { + return new ErrorDTO.ErrorDTOBuilder(); + } + + @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "") + public static class ErrorDTOBuilder { + + private String message; + + public ErrorDTO.ErrorDTOBuilder message(String message) { + this.message = message; + return this; + } + + public ErrorDTO build() { + ErrorDTO errorDTO = new ErrorDTO(this); + return errorDTO; + } + } + + @Schema(name = "message", required = false) + public String getMessage() { + return message; + } + public void setMessage(String message) { + this.message = message; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ErrorDTO errorDTO = (ErrorDTO) o; + return Objects.equals(this.message, errorDTO.message); + } + + @Override + public int hashCode() { + return Objects.hash(message); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("ErrorDTO{"); + sb.append(" message:").append(message); + sb.append("}"); + return sb.toString(); + } + + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/EventsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/EventsApi.java new file mode 100644 index 00000000..afb483d7 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/EventsApi.java @@ -0,0 +1,54 @@ +package com.sngular.multifileplugin.ndjsonstreaming; + +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 java.util.stream.Stream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import com.sngular.multifileplugin.ndjsonstreaming.model.InlineResponse200StreamEventsDTO; + +public interface EventsApi { + + /** + * GET /events, sending the items {@link #streamEvents} returns as application/x-ndjson, one JSON document per line. + */ + @Operation( + operationId = "streamEvents", + tags = {"events"}, + responses = { + @ApiResponse(responseCode = "200", description = "One event per line", content = @Content(mediaType = "application/x-ndjson", schema = @Schema(implementation = InlineResponse200StreamEventsDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/events", + produces = {"application/x-ndjson"} + ) + default ResponseEntity streamEventsNdjson(@Parameter(hidden = true) final HttpServletRequest servletRequest, @Parameter(hidden = true) final HttpServletResponse servletResponse) { + return NdjsonSupport.stream(streamEvents(), servletRequest, servletResponse); + } + + /** + * GET /events. Implement this method rather than the endpoint above: the items are sent as they are produced, and the stream is closed once they are sent. + */ + default ResponseEntity> streamEvents() { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/ImportResultDTO.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/ImportResultDTO.java new file mode 100644 index 00000000..288d7360 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/ImportResultDTO.java @@ -0,0 +1,76 @@ +package com.sngular.multifileplugin.ndjsonstreaming.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 = ImportResultDTO.ImportResultDTOBuilder.class) +public class ImportResultDTO { + + @JsonProperty(value ="imported") + private Integer imported; + + private ImportResultDTO(ImportResultDTOBuilder builder) { + this.imported = builder.imported; + + } + + public static ImportResultDTO.ImportResultDTOBuilder builder() { + return new ImportResultDTO.ImportResultDTOBuilder(); + } + + @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "") + public static class ImportResultDTOBuilder { + + private Integer imported; + + public ImportResultDTO.ImportResultDTOBuilder imported(Integer imported) { + this.imported = imported; + return this; + } + + public ImportResultDTO build() { + ImportResultDTO importResultDTO = new ImportResultDTO(this); + return importResultDTO; + } + } + + @Schema(name = "imported", required = false) + public Integer getImported() { + return imported; + } + public void setImported(Integer imported) { + this.imported = imported; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ImportResultDTO importResultDTO = (ImportResultDTO) o; + return Objects.equals(this.imported, importResultDTO.imported); + } + + @Override + public int hashCode() { + return Objects.hash(imported); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("ImportResultDTO{"); + sb.append(" imported:").append(imported); + sb.append("}"); + return sb.toString(); + } + + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/InlineResponse200StreamEventsDTO.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/InlineResponse200StreamEventsDTO.java new file mode 100644 index 00000000..44ef15e0 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/InlineResponse200StreamEventsDTO.java @@ -0,0 +1,94 @@ +package com.sngular.multifileplugin.ndjsonstreaming.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 = InlineResponse200StreamEventsDTO.InlineResponse200StreamEventsDTOBuilder.class) +public class InlineResponse200StreamEventsDTO { + + @JsonProperty(value ="type") + private String type; + @JsonProperty(value ="id") + private String id; + + private InlineResponse200StreamEventsDTO(InlineResponse200StreamEventsDTOBuilder builder) { + this.type = builder.type; + this.id = builder.id; + + } + + public static InlineResponse200StreamEventsDTO.InlineResponse200StreamEventsDTOBuilder builder() { + return new InlineResponse200StreamEventsDTO.InlineResponse200StreamEventsDTOBuilder(); + } + + @JsonPOJOBuilder(buildMethodName = "build", withPrefix = "") + public static class InlineResponse200StreamEventsDTOBuilder { + + private String type; + private String id; + + public InlineResponse200StreamEventsDTO.InlineResponse200StreamEventsDTOBuilder type(String type) { + this.type = type; + return this; + } + + public InlineResponse200StreamEventsDTO.InlineResponse200StreamEventsDTOBuilder id(String id) { + this.id = id; + return this; + } + + public InlineResponse200StreamEventsDTO build() { + InlineResponse200StreamEventsDTO inlineResponse200StreamEventsDTO = new InlineResponse200StreamEventsDTO(this); + return inlineResponse200StreamEventsDTO; + } + } + + @Schema(name = "type", required = false) + public String getType() { + return type; + } + public void setType(String type) { + this.type = type; + } + + @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; + } + InlineResponse200StreamEventsDTO inlineResponse200StreamEventsDTO = (InlineResponse200StreamEventsDTO) o; + return Objects.equals(this.type, inlineResponse200StreamEventsDTO.type) && Objects.equals(this.id, inlineResponse200StreamEventsDTO.id); + } + + @Override + public int hashCode() { + return Objects.hash(type, id); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("InlineResponse200StreamEventsDTO{"); + sb.append(" type:").append(type).append(","); + sb.append(" id:").append(id); + sb.append("}"); + return sb.toString(); + } + + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/ItemDTO.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/ItemDTO.java new file mode 100644 index 00000000..2f7426a4 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/ItemDTO.java @@ -0,0 +1,94 @@ +package com.sngular.multifileplugin.ndjsonstreaming.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/testNdjsonStreaming/assets/imperative/ItemsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/ItemsApi.java new file mode 100644 index 00000000..9b9402b4 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/ItemsApi.java @@ -0,0 +1,153 @@ +package com.sngular.multifileplugin.ndjsonstreaming; + +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 java.util.stream.Stream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import com.fasterxml.jackson.core.type.TypeReference; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import com.sngular.multifileplugin.ndjsonstreaming.model.ItemDTO; +import com.sngular.multifileplugin.ndjsonstreaming.model.ErrorDTO; +import com.sngular.multifileplugin.ndjsonstreaming.model.ImportResultDTO; + +public interface ItemsApi { + + /** + * GET /items, sending the items {@link #listItems} returns as application/x-ndjson, one JSON document per line. + */ + @Operation( + operationId = "listItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "One item per line", content = @Content(mediaType = "application/x-ndjson", schema = @Schema(implementation = ItemDTO.class))), + @ApiResponse(responseCode = "400", description = "The filter is not valid", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items", + produces = {"application/x-ndjson"} + ) + default ResponseEntity listItemsNdjson(@Parameter(name = "filter", required = false, schema = @Schema(description = "")) @RequestParam(name = "filter", required = false) String filter, @Parameter(hidden = true) final HttpServletRequest servletRequest, @Parameter(hidden = true) final HttpServletResponse servletResponse) { + return NdjsonSupport.stream(listItems(filter), servletRequest, servletResponse); + } + + /** + * GET /items. Implement this method rather than the endpoint above: the items are sent as they are produced, and the stream is closed once they are sent. + */ + default ResponseEntity> listItems(final String filter) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * GET /items/export, sending the items {@link #exportItems} returns as application/x-ndjson, one JSON document per line. + */ + @Operation( + operationId = "exportItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The items, as a JSON array or one per line", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = List.class)), @Content(mediaType = "application/x-ndjson", schema = @Schema(implementation = ItemDTO.class))}) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items/export", + produces = {"application/x-ndjson"} + ) + default ResponseEntity exportItemsNdjson(@Parameter(hidden = true) final HttpServletRequest servletRequest, @Parameter(hidden = true) final HttpServletResponse servletResponse) { + return NdjsonSupport.stream(exportItems(), servletRequest, servletResponse); + } + + /** + * GET /items/export, sending the items {@link #exportItems} returns as a JSON array. + */ + @Operation( + operationId = "exportItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The items, as a JSON array or one per line", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = List.class)), @Content(mediaType = "application/x-ndjson", schema = @Schema(implementation = ItemDTO.class))}) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items/export", + produces = {"application/json"} + ) + default ResponseEntity> exportItemsJson() { + return NdjsonSupport.collect(exportItems()); + } + + /** + * GET /items/export. Implement this method rather than the endpoints above: the items are sent as they are produced, and the stream is closed once they are sent. + */ + default ResponseEntity> exportItems() { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * POST /items/import, reading the application/x-ndjson body, one JSON document per line, as the items {@link #importItems} receives. + */ + @Operation( + operationId = "importItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "Import summary", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ImportResultDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.POST, + value = "/items/import", + produces = {"application/json"}, + consumes = {"application/x-ndjson"} + ) + default ResponseEntity importItemsNdjson(@Parameter(hidden = true) final HttpServletRequest servletRequest) throws IOException { + return importItems(NdjsonSupport.read(servletRequest, new TypeReference() {})); + } + + /** + * POST /items/import. Implement this method rather than the endpoint above: the body items are read as the stream is consumed. + */ + default ResponseEntity importItems(final Stream itemDTO) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * GET /items/{itemId} + * @param itemId true + * @return The item; (status code 200) + */ + + @Operation( + operationId = "getItem", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The item", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ItemDTO.class))) + } + ) + @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); + } + +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/NdjsonSupport.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/NdjsonSupport.java new file mode 100644 index 00000000..aa8abfe7 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/NdjsonSupport.java @@ -0,0 +1,97 @@ +package com.sngular.multifileplugin.ndjsonstreaming; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Iterator; +import java.util.List; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.ApplicationContext; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import org.springframework.web.servlet.support.RequestContextUtils; + +/** + * Sends and reads application/x-ndjson, one JSON document per line, for the API interfaces of this package. Streamed + * responses are written by Spring MVC on its asynchronous executor, each item sent to the client as soon as it is produced, + * and the stream the operation returns is closed once it is sent, also when sending fails. Items are serialized with the + * application's ObjectMapper. + */ +public final class NdjsonSupport { + + private static final MediaType APPLICATION_NDJSON = MediaType.parseMediaType("application/x-ndjson"); + + private NdjsonSupport() { + } + + /** + * The response streaming the items of the given one, with its status and headers. A response without items, such as the + * NOT_IMPLEMENTED of an operation that is not implemented, is sent without a body. + */ + public static ResponseEntity stream(final ResponseEntity> response, final HttpServletRequest servletRequest, + final HttpServletResponse servletResponse) { + final ResponseEntity.BodyBuilder builder = ResponseEntity.status(response.getStatusCode()).headers(response.getHeaders()); + final Stream items = response.getBody(); + if (items == null) { + return builder.build(); + } + final ObjectMapper mapper = objectMapper(servletRequest); + return builder.contentType(APPLICATION_NDJSON).body(output -> write(items, mapper, output, servletResponse)); + } + + /** The response sending the items of the given one as a JSON array, with its status and headers. */ + public static ResponseEntity> collect(final ResponseEntity> response) { + final ResponseEntity.BodyBuilder builder = ResponseEntity.status(response.getStatusCode()).headers(response.getHeaders()); + final Stream items = response.getBody(); + if (items == null) { + return builder.build(); + } + try (Stream closing = items) { + return builder.body(closing.collect(Collectors.toList())); + } + } + + /** The items of the request body, read one line at a time as the stream is consumed. */ + public static Stream read(final HttpServletRequest servletRequest, final TypeReference itemType) throws IOException { + final Iterator items = objectMapper(servletRequest).readerFor(itemType).readValues(servletRequest.getInputStream()); + return StreamSupport.stream(Spliterators.spliteratorUnknownSize(items, Spliterator.ORDERED), false); + } + + /** + * Writes each item and sends it at once. The response is flushed rather than the output stream, whose flush Spring + * Framework 7 ignores by default. + */ + private static void write(final Stream items, final ObjectMapper mapper, final OutputStream output, final HttpServletResponse servletResponse) + throws IOException { + try (Stream closing = items) { + final Iterator iterator = closing.iterator(); + while (iterator.hasNext()) { + output.write(mapper.writeValueAsBytes(iterator.next())); + output.write('\n'); + servletResponse.flushBuffer(); + } + } + } + + /** The application's ObjectMapper, so items are sent as its other responses are, or a default one when it has none. */ + private static ObjectMapper objectMapper(final HttpServletRequest servletRequest) { + final ApplicationContext context = RequestContextUtils.findWebApplicationContext(servletRequest); + ObjectMapper mapper = null; + if (context != null) { + mapper = context.getBeanProvider(ObjectMapper.class).getIfUnique(); + } + if (mapper == null) { + mapper = new ObjectMapper().findAndRegisterModules(); + } + return mapper; + } +} diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/reactive/EventsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/reactive/EventsApi.java new file mode 100644 index 00000000..e4d671e5 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/reactive/EventsApi.java @@ -0,0 +1,49 @@ +package com.sngular.multifileplugin.ndjsonstreamingreactive; + +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.ndjsonstreamingreactive.model.InlineResponse200StreamEventsDTO; + +public interface EventsApi { + + /** + * GET /events + * @return One event per line; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + @Operation( + operationId = "streamEvents", + tags = {"events"}, + responses = { + @ApiResponse(responseCode = "200", description = "One event per line", content = @Content(mediaType = "application/x-ndjson", schema = @Schema(implementation = InlineResponse200StreamEventsDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/events", + produces = {"application/x-ndjson"} + ) + default ResponseEntity> streamEvents(@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/testNdjsonStreaming/assets/reactive/ItemsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/reactive/ItemsApi.java new file mode 100644 index 00000000..ca926590 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/reactive/ItemsApi.java @@ -0,0 +1,119 @@ +package com.sngular.multifileplugin.ndjsonstreamingreactive; + +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.ndjsonstreamingreactive.model.ItemDTO; +import com.sngular.multifileplugin.ndjsonstreamingreactive.model.ErrorDTO; +import com.sngular.multifileplugin.ndjsonstreamingreactive.model.ImportResultDTO; + +public interface ItemsApi { + + /** + * GET /items + * @param filter false + * @return One item per line; (status code 200) The filter is not valid; (status code 400) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + @Operation( + operationId = "listItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "One item per line", content = @Content(mediaType = "application/x-ndjson", schema = @Schema(implementation = ItemDTO.class))), + @ApiResponse(responseCode = "400", description = "The filter is not valid", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items", + produces = {"application/x-ndjson"} + ) + default ResponseEntity> listItems(@Parameter(name = "filter", required = false, schema = @Schema(description = "")) @RequestParam(name = "filter", required = false) String filter, @ApiIgnore final ServerWebExchange exchange) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * GET /items/export + * @return The items, as a JSON array or one per line; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + @Operation( + operationId = "exportItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "The items, as a JSON array or one per line", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ItemDTO.class)), @Content(mediaType = "application/x-ndjson", schema = @Schema(implementation = ItemDTO.class))}) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = "/items/export", + produces = {"application/json", "application/x-ndjson"} + ) + default ResponseEntity> exportItems(@ApiIgnore final ServerWebExchange exchange) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * POST /items/import + * @param itemDTO (required) + * @return Import summary; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + @Operation( + operationId = "importItems", + tags = {"items"}, + responses = { + @ApiResponse(responseCode = "200", description = "Import summary", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ImportResultDTO.class))) + } + ) + @RequestMapping( + method = RequestMethod.POST, + value = "/items/import", + produces = {"application/json"}, + consumes = {"application/x-ndjson"} + ) + default ResponseEntity> importItems(@Parameter(name = "itemDTO", description = "", required = true, schema = @Schema(description = "")) @Valid @RequestBody Flux itemDTO, @ApiIgnore final ServerWebExchange exchange) { + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * GET /items/{itemId} + * @param itemId true + * @return The item; (status code 200) + * @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))) + } + ) + @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); + } + +} \ No newline at end of file diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/webclient/EventsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/webclient/EventsApi.java new file mode 100644 index 00000000..e948f9a7 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/webclient/EventsApi.java @@ -0,0 +1,110 @@ +package com.sngular.multifileplugin.ndjsonstreamingwebclient; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.sngular.multifileplugin.ndjsonstreamingwebclient.client.ApiWebClient; +import com.sngular.multifileplugin.ndjsonstreamingwebclient.model.InlineResponse200StreamEventsDTO; + +import com.sngular.multifileplugin.ndjsonstreamingwebclient.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 EventsApi { + + private ApiWebClient apiWebClient; + + private Map authenticationsApi; + + private String basePath = ""; + + /** + * Builds its own ApiWebClient, sending requests to the contract's first server. + */ + public EventsApi() { + 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 EventsApi(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 EventsApi(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); + } + + /** + * GET /events: "" + * @return One event per line; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + private ResponseSpec streamEventsRequestCreation() 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 = {"application/x-ndjson"}; + 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,"/events", HttpMethod.GET, pathParams, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + + } + + /** + * GET /events + * @return One event per line; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + public Flux streamEvents() throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return streamEventsRequestCreation().bodyToFlux(localVarReturnType); + } + + public Mono>> streamEventsWithHttpInfo() throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return streamEventsRequestCreation().toEntityFlux(localVarReturnType); + } + +} \ No newline at end of file diff --git a/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/webclient/ItemsApi.java b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/webclient/ItemsApi.java new file mode 100644 index 00000000..227a3810 --- /dev/null +++ b/multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/webclient/ItemsApi.java @@ -0,0 +1,245 @@ +package com.sngular.multifileplugin.ndjsonstreamingwebclient; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.sngular.multifileplugin.ndjsonstreamingwebclient.client.ApiWebClient; +import com.sngular.multifileplugin.ndjsonstreamingwebclient.model.ItemDTO; +import com.sngular.multifileplugin.ndjsonstreamingwebclient.model.ErrorDTO; +import com.sngular.multifileplugin.ndjsonstreamingwebclient.model.ImportResultDTO; + +import com.sngular.multifileplugin.ndjsonstreamingwebclient.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); + } + + /** + * GET /items: "" + * @param filter false + * @return One item per line; (status code 200) The filter is not valid; (status code 400) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + private ResponseSpec listItemsRequestCreation(String filter) 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.parameterToMultiValueMap( null, "filter", filter)); + + final String[] localVarAccepts = {"application/x-ndjson"}; + 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.GET, pathParams, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + + } + + /** + * GET /items + * @param filter + * @return One item per line; (status code 200) The filter is not valid; (status code 400) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + public Flux listItems(String filter) throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return listItemsRequestCreation(filter).bodyToFlux(localVarReturnType); + } + + public Mono>> listItemsWithHttpInfo(String filter) throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return listItemsRequestCreation(filter).toEntityFlux(localVarReturnType); + } + + /** + * GET /items/export: "" + * @return The items, as a JSON array or one per line; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + private ResponseSpec exportItemsRequestCreation() 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 = {"application/x-ndjson"}; + 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/export", HttpMethod.GET, pathParams, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + + } + + /** + * GET /items/export + * @return The items, as a JSON array or one per line; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + public Flux exportItems() throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return exportItemsRequestCreation().bodyToFlux(localVarReturnType); + } + + public Mono>> exportItemsWithHttpInfo() throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return exportItemsRequestCreation().toEntityFlux(localVarReturnType); + } + + /** + * POST /items/import: "" + * @param itemDTO (required) + * @return Import summary; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + private ResponseSpec importItemsRequestCreation(Flux itemDTO) throws WebClientResponseException { + Object postBody = itemDTO; + if (itemDTO == null) { + throw new WebClientResponseException("Missing the required parameter ''itemDTO'' when calling importItems", 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/x-ndjson"}; + final MediaType localVarContentType = apiWebClient.selectHeaderContentType(localVarContentTypes); + + String[] localVarAuthNames = new String[] {}; + + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return apiWebClient.invokeAPI(basePath,"/items/import", HttpMethod.POST, pathParams, queryParams, postBody, headerParams, cookieParams, formParams, localVarAccept, localVarContentType, localVarAuthNames, localVarReturnType); + + } + + /** + * POST /items/import + * @param itemDTO (required) + * @return Import summary; (status code 200) + * @throws WebClientResponseException if an error occurs while attempting to invoke the API + */ + public Mono importItems(Flux itemDTO) throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return importItemsRequestCreation(itemDTO).bodyToMono(localVarReturnType); + } + + public Mono> importItemsWithHttpInfo(Flux itemDTO) throws WebClientResponseException { + ParameterizedTypeReference localVarReturnType = new ParameterizedTypeReference() {}; + return importItemsRequestCreation(itemDTO).toEntity(localVarReturnType); + } + + /** + * GET /items/{itemId}: "" + * @param itemId true + * @return The item; (status code 200) + * @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) + * @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); + } + +} \ No newline at end of file diff --git a/multiapi-engine/src/test/resources/openapigenerator/testWebClientApiGeneration/assets/springboot4/client/ApiWebClient.java b/multiapi-engine/src/test/resources/openapigenerator/testWebClientApiGeneration/assets/springboot4/client/ApiWebClient.java index 4d3814f0..69a1b0e8 100644 --- a/multiapi-engine/src/test/resources/openapigenerator/testWebClientApiGeneration/assets/springboot4/client/ApiWebClient.java +++ b/multiapi-engine/src/test/resources/openapigenerator/testWebClientApiGeneration/assets/springboot4/client/ApiWebClient.java @@ -35,6 +35,7 @@ import org.springframework.web.client.RestClientException; import org.springframework.web.reactive.function.BodyInserter; import org.springframework.web.reactive.function.BodyInserters; +import org.reactivestreams.Publisher; import org.springframework.web.reactive.function.client.ExchangeStrategies; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient.ResponseSpec; @@ -136,11 +137,13 @@ private static JsonMapper createDefaultObjectMapper(final DateFormat defaultDate } private static WebClient buildWebClient(final JsonMapper mapper) { + // application/x-ndjson is read and written with the same mapper, one item per line. + final MediaType ndjson = new MediaType("application", "x-ndjson"); ExchangeStrategies strategies = ExchangeStrategies .builder() .codecs(clientDefaultCodecsConfigurer -> { - clientDefaultCodecsConfigurer.defaultCodecs().jacksonJsonEncoder(new JacksonJsonEncoder(mapper, MediaType.APPLICATION_JSON)); - clientDefaultCodecsConfigurer.defaultCodecs().jacksonJsonDecoder(new JacksonJsonDecoder(mapper, MediaType.APPLICATION_JSON)); + clientDefaultCodecsConfigurer.defaultCodecs().jacksonJsonEncoder(new JacksonJsonEncoder(mapper, MediaType.APPLICATION_JSON, ndjson)); + clientDefaultCodecsConfigurer.defaultCodecs().jacksonJsonDecoder(new JacksonJsonDecoder(mapper, MediaType.APPLICATION_JSON, ndjson)); }).build(); WebClient.Builder webClientBuilder = WebClient.builder().exchangeStrategies(strategies); return webClientBuilder.build(); @@ -331,6 +334,11 @@ public MediaType selectHeaderContentType(final String[] contentTypes) { return BodyInserters.fromFormData(map); } else if(MediaType.MULTIPART_FORM_DATA.equals(contentType)) { return BodyInserters.fromMultipartData(formParams); + } else if (obj instanceof Publisher) { + // A streamed body, such as application/x-ndjson, is sent item by item as it is published. + @SuppressWarnings("unchecked") + final Publisher items = (Publisher) obj; + return BodyInserters.fromPublisher(items, Object.class); } else { return obj != null ? BodyInserters.fromValue(obj) : null; } diff --git a/scs-multiapi-gradle-plugin/build.gradle b/scs-multiapi-gradle-plugin/build.gradle index 95be8229..6ee459d5 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.2.1' +version = '8.3.0' def SCSMultiApiPluginGroupId = group def SCSMultiApiPluginVersion = version @@ -31,7 +31,7 @@ dependencies { shadow localGroovy() shadow gradleApi() - implementation 'com.sngular:multiapi-engine:8.2.1' + implementation 'com.sngular:multiapi-engine:8.3.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 bb0cc3f5..f97b5455 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.2.1 + 8.3.0 maven-plugin AsyncApi - OpenApi Code Generator Maven Plugin @@ -272,7 +272,7 @@ com.sngular multiapi-engine - 8.2.1 + 8.3.0 org.apache.maven