From 89776d4e6c87f8bc793f273b06fbae847f08b89b Mon Sep 17 00:00:00 2001 From: joseegarcia Date: Tue, 29 Sep 2026 13:54:35 +0200 Subject: [PATCH 1/3] Stream application/x-ndjson endpoints without making Spring MVC services reactive An operation whose successful response or request body is application/x-ndjson (or application/stream+json) is detected from the contract; its schema, the items of an array schema, or an OpenAPI 3.2 itemSchema, is the type of each line. - Spring MVC servers split the operation into the endpoint Spring maps, which is not overridden, and a plain method to implement returning ResponseEntity> (or taking a Stream body). The generated NdjsonSupport sends each item as it is produced through StreamingResponseBody, with the application's ObjectMapper, closes the stream, and flushes the servlet response, since Spring Framework 7.0.6 ignores flushing the body stream. A JSON alternative of the same response, which must be the array of the items, gets its own endpoint picked by the Accept header. - WebFlux servers, WebClient clients and reactive @HttpExchange interfaces take and return Flux; ApiWebClient reads and writes NDJSON and sends Publisher bodies. - Blocking clients read a response whole, so generation fails for a streaming operation and asks for reactive=true. Also: itemSchema no longer crashes the generator, and a response declaring several media types renders a valid @ApiResponse content array. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 61 +++++ .../plugin/openapi/OpenApiGenerator.java | 1 + .../plugin/openapi/model/ContentObject.java | 6 + .../plugin/openapi/model/OperationObject.java | 14 + .../plugin/openapi/model/RequestObject.java | 3 + .../template/ClasspathTemplateLoader.java | 2 +- .../openapi/template/TemplateFactory.java | 13 + .../template/TemplateIndexConstants.java | 2 + .../plugin/openapi/utils/MapperPathUtil.java | 80 +++++- .../plugin/openapi/utils/OpenApiUtil.java | 21 ++ .../resources/templates/openapi/template.ftlh | 139 +++++++++- .../openapi/templateCallHttpExchange.ftlh | 13 +- .../openapi/templateCallWebClient.ftlh | 24 +- .../openapi/templateNdjsonSupport.ftlh | 105 ++++++++ .../templates/openapi/templateReactive.ftlh | 17 +- .../templates/openapi/templateWebClient.ftlh | 16 +- .../openapi/OpenApiGeneratorFixtures.java | 65 +++++ .../openapi/OpenApiGeneratorJakartaTest.java | 3 + .../OpenApiGeneratorSpringBoot4Test.java | 16 ++ .../plugin/openapi/OpenApiGeneratorTest.java | 35 +++ .../testNdjsonStreaming/api-json-mismatch.yml | 33 +++ .../testNdjsonStreaming/api-mixed-request.yml | 29 +++ .../testNdjsonStreaming/api-test.yml | 116 +++++++++ .../assets/httpexchange/EventsApi.java | 41 +++ .../assets/httpexchange/ItemsApi.java | 67 +++++ .../assets/imperative-boot3/EventsApi.java | 54 ++++ .../assets/imperative-boot3/ItemsApi.java | 153 +++++++++++ .../imperative-boot3/NdjsonSupport.java | 97 +++++++ .../assets/imperative-boot4/EventsApi.java | 54 ++++ .../assets/imperative-boot4/ItemsApi.java | 153 +++++++++++ .../imperative-boot4/NdjsonSupport.java | 98 +++++++ .../assets/imperative/ErrorDTO.java | 76 ++++++ .../assets/imperative/EventsApi.java | 54 ++++ .../assets/imperative/ImportResultDTO.java | 76 ++++++ .../InlineResponse200StreamEventsDTO.java | 94 +++++++ .../assets/imperative/ItemDTO.java | 94 +++++++ .../assets/imperative/ItemsApi.java | 153 +++++++++++ .../assets/imperative/NdjsonSupport.java | 97 +++++++ .../assets/reactive/EventsApi.java | 49 ++++ .../assets/reactive/ItemsApi.java | 119 +++++++++ .../assets/webclient/EventsApi.java | 110 ++++++++ .../assets/webclient/ItemsApi.java | 245 ++++++++++++++++++ .../springboot4/client/ApiWebClient.java | 12 +- 43 files changed, 2682 insertions(+), 28 deletions(-) create mode 100644 multiapi-engine/src/main/resources/templates/openapi/templateNdjsonSupport.ftlh create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/api-json-mismatch.yml create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/api-mixed-request.yml create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/api-test.yml create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/httpexchange/EventsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/httpexchange/ItemsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot3/EventsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot3/ItemsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot3/NdjsonSupport.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot4/EventsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot4/ItemsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative-boot4/NdjsonSupport.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/ErrorDTO.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/EventsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/ImportResultDTO.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/InlineResponse200StreamEventsDTO.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/ItemDTO.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/ItemsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/imperative/NdjsonSupport.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/reactive/EventsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/reactive/ItemsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/webclient/EventsApi.java create mode 100644 multiapi-engine/src/test/resources/openapigenerator/testNdjsonStreaming/assets/webclient/ItemsApi.java diff --git a/README.md b/README.md index 22d1b80fb..baaec7d45 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/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 1770f5ab1..8c24a2a8c 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 b813ccc39..6991f20c1 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 9f20fb700..d1a7d51ef 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 ab2b269a7..80fa11c12 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 cbf7dc5a8..d5e211615 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 82501aabb..c9bf7de2f 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 f525aae9a..dc72faed8 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 af3c052eb..3a56b3111 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 e6f3e2785..4898057fc 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 baddcb54c..8c45b92ef 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 d3de136ed..7bd4fab5b 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 0e85da0c2..cb813d463 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 000000000..7c7a64195 --- /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 811d7f455..e11e7ea67 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 53537be79..99ca6f532 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 e2c55367a..9e78675a2 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 94f739b46..1a8c6a9f6 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 075cb70bc..e8862798d 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 971effd53..1c973178a 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 000000000..5674e62e4 --- /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 000000000..c0122d54d --- /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 000000000..2d2f365a3 --- /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 000000000..bfc47c774 --- /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 000000000..d2917d5e5 --- /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 000000000..34e21974b --- /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 000000000..eeba0b356 --- /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 000000000..096763050 --- /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 000000000..34e21974b --- /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 000000000..535bed4b6 --- /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 000000000..0ae8f5d34 --- /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 000000000..df7584933 --- /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 000000000..afb483d7f --- /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 000000000..288d73608 --- /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 000000000..44ef15e0e --- /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 000000000..2f7426a4f --- /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 000000000..9b9402b4b --- /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 000000000..aa8abfe7d --- /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 000000000..e4d671e55 --- /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 000000000..ca926590d --- /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 000000000..e948f9a79 --- /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 000000000..227a38107 --- /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 4d3814f02..69a1b0e8a 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; } From 60ced18b82a319d3de31ed9fdd03847b2aefccd7 Mon Sep 17 00:00:00 2001 From: joseegarcia Date: Tue, 29 Sep 2026 13:54:35 +0200 Subject: [PATCH 2/3] Bump version to 8.3.0 Co-Authored-By: Claude Opus 5.5 (1M context) --- multiapi-engine/pom.xml | 2 +- scs-multiapi-gradle-plugin/build.gradle | 4 ++-- scs-multiapi-maven-plugin/pom.xml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/multiapi-engine/pom.xml b/multiapi-engine/pom.xml index c903644ea..66440ce90 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/scs-multiapi-gradle-plugin/build.gradle b/scs-multiapi-gradle-plugin/build.gradle index 95be82291..6ee459d55 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 bb0cc3f5f..f97b5455a 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 From 6bb78d22fe8c6561a1afd6db482f791e6e747cdf Mon Sep 17 00:00:00 2001 From: joseegarcia Date: Tue, 29 Sep 2026 13:58:32 +0200 Subject: [PATCH 3/3] Wrap a README line to 80 columns Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index baaec7d45..6837463ec 100644 --- a/README.md +++ b/README.md @@ -1131,8 +1131,8 @@ for it. 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 + 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