From 6949c56b09e474e7f9d1076aaab3a5b0d1a2c826 Mon Sep 17 00:00:00 2001 From: Clement Escoffier Date: Tue, 23 Nov 2021 15:49:14 +0100 Subject: [PATCH] Provide another way to generate streams from Reactive Routes that do not require a specific Multi sub-type. Sub-typing Multi does not work when the route is protected using @RolesAllowed as the security interceptor changes the returned Multi actual type. The previous approach is still supported but is deprecated. The documentation mentions the new approach and explains why the previous approach should be avoided. --- docs/src/main/asciidoc/reactive-routes.adoc | 55 +++- .../web/deployment/HandlerDescriptor.java | 40 ++- .../quarkus/vertx/web/deployment/Methods.java | 4 +- .../deployment/ReactiveRoutesProcessor.java | 111 ++++--- ...=> JsonMultiRouteWithAsJsonArrayTest.java} | 2 +- .../JsonMultiRouteWithContentTypeTest.java | 145 +++++++++ ...onStreamMultiRouteWithContentTypeTest.java | 166 ++++++++++ ...NdjsonMultiRouteWithAsJsonStreamTest.java} | 2 +- .../NdjsonMultiRouteWithContentTypeTest.java | 167 ++++++++++ ...> SSEMultiRouteWithAsEventStreamTest.java} | 2 +- .../SSEMultiRouteWithContentTypeTest.java | 295 ++++++++++++++++++ .../io/quarkus/vertx/web/ReactiveRoutes.java | 77 +++++ .../main/java/io/quarkus/vertx/web/Route.java | 18 +- 13 files changed, 1000 insertions(+), 84 deletions(-) rename extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/{JsonMultiRouteTest.java => JsonMultiRouteWithAsJsonArrayTest.java} (99%) create mode 100644 extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonMultiRouteWithContentTypeTest.java create mode 100644 extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonStreamMultiRouteWithContentTypeTest.java rename extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/{NdjsonMultiRouteTest.java => NdjsonMultiRouteWithAsJsonStreamTest.java} (99%) create mode 100644 extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/NdjsonMultiRouteWithContentTypeTest.java rename extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/{SSEMultiRouteTest.java => SSEMultiRouteWithAsEventStreamTest.java} (99%) create mode 100644 extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/SSEMultiRouteWithContentTypeTest.java diff --git a/docs/src/main/asciidoc/reactive-routes.adoc b/docs/src/main/asciidoc/reactive-routes.adoc index 71b25befeffc3..a402732906dd9 100644 --- a/docs/src/main/asciidoc/reactive-routes.adoc +++ b/docs/src/main/asciidoc/reactive-routes.adoc @@ -399,18 +399,16 @@ The previous snippet produces: You can return a `Multi` to produce a JSON Array, where every item is an item from this array. The response is written item by item to the client. -The `content-type` is set to `application/json` if not set already. - -To use this feature, you need to wrap the returned `Multi` using `io.quarkus.vertx.web.ReactiveRoutes.asJsonArray`: +To do that set the `produces` attribute to `"application/json"` (or `ReactiveRoutes.APPLICATION_JSON`). [source, java] ---- -@Route(path = "/people") +@Route(path = "/people", produces = ReactiveRoutes.APPLICATION_JSON) Multi people(RoutingContext context) { - return ReactiveRoutes.asJsonArray(Multi.createFrom().items( + return Multi.createFrom().items( new Person("superman", 1), new Person("batman", 2), - new Person("spiderman", 3))); + new Person("spiderman", 3)); } ---- @@ -425,24 +423,34 @@ The previous snippet produces: ] ---- +TIP: The `produces` attribute is an array. +When you pass a single value you can omit the "{" and "}". +Note that `"application/json"` must be the first value in the array. + Only `Multi`, `Multi` and `Multi` can be written into the JSON Array. Using a `Multi` produces an empty array. You cannot use `Multi`. If you need to use `Buffer`, transform the content into a JSON or String representation first. +[NOTE] +.Deprecation of `asJsonArray` +==== +The `ReactiveRoutes.asJsonArray` has been deprecated as it is not compatible with the security layer of Quarkus. +==== + === Event Stream and Server-Sent Event support You can return a `Multi` to produce an event source (stream of server sent events). -To enable this feature, you need to wrap the returned `Multi` using `io.quarkus.vertx.web.ReactiveRoutes.asEventStream`: +To enable this feature, set the `produces` attribute to `"text/event-stream"` (or `ReactiveRoutes.EVENT_STREAM`), such as in: [source, java] ---- -@Route(path = "/people") +@Route(path = "/people", produces = ReactiveRoutes.EVENT_STREAM) Multi people(RoutingContext context) { - return ReactiveRoutes.asEventStream(Multi.createFrom().items( + return Multi.createFrom().items( new Person("superman", 1), new Person("batman", 2), - new Person("spiderman", 3))); + new Person("spiderman", 3)); } ---- @@ -461,6 +469,10 @@ id: 2 ---- +TIP: The `produces` attribute is an array. +When you pass a single value you can omit the "{" and "}". +Note that `"text/event-stream"` must be the first value in the array. + You can also implement the `io.quarkus.vertx.web.ReactiveRoutes.ServerSentEvent` interface to customize the `event` and `id` section of the server sent event: [source, java] @@ -491,7 +503,7 @@ class PersonEvent implements ReactiveRoutes.ServerSentEvent { } ---- -Using a `Multi` (wrapped using `io.quarkus.vertx.web.ReactiveRoutes.asEventStream`) would produce: +Using a `Multi` would produce: [source, text] ---- @@ -509,14 +521,20 @@ id: 3 ---- +[NOTE] +.Deprecation of `asEventStream` +==== +The `ReactiveRoutes.asEventStream` has been deprecated as it is not compatible with the security layer of Quarkus. +==== + === Json Stream in NDJSON format You can return a `Multi` to produce a newline delimited stream of JSON values. -To enable this feature, you need to wrap the returned `Multi` using `io.quarkus.vertx.web.ReactiveRoutes.asJsonStream`: +To enable this feature, set the `produces` attribute of the `@Route` annotation to `"application/x-ndjson"` (or `ReactiveRoutes.ND_JSON`): [source, java] ---- -@Route(path = "/people") +@Route(path = "/people", produces = ReactiveRoutes.ND_JSON) Multi people(RoutingContext context) { return ReactiveRoutes.asJsonStream(Multi.createFrom().items( new Person("superman", 1), @@ -536,11 +554,14 @@ This method would produce: ---- +TIP: The `produces` attribute is an array. When you pass a single value you can omit the "{" and "}". +Note that `"application/x-ndjson"` must be the first value in the array. + You can also provide strings instead of Objects, in that case the strings will be wrapped in quotes to become valid JSON values: [source, java] ---- -@Route(path = "/people") +@Route(path = "/people", produces = ReactiveRoutes.ND_JSON) Multi people(RoutingContext context) { return ReactiveRoutes.asJsonStream(Multi.createFrom().items( "superman", @@ -558,6 +579,12 @@ Multi people(RoutingContext context) { ---- +[NOTE] +.Deprecation of `asJsonStream` +==== +The `ReactiveRoutes.asJsonStream` has been deprecated as it is not compatible with the security layer of Quarkus. +==== + === Using Bean Validation You can combine reactive routes and Bean Validation. diff --git a/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/HandlerDescriptor.java b/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/HandlerDescriptor.java index 8672d08fa074e..61eafeee5f513 100644 --- a/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/HandlerDescriptor.java +++ b/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/HandlerDescriptor.java @@ -16,33 +16,32 @@ class HandlerDescriptor { private final MethodInfo method; private final BeanValidationAnnotationsBuildItem validationAnnotations; private final HandlerType handlerType; - private final Type contentType; + private final Type payloadType; + private final String[] contentTypes; - HandlerDescriptor(MethodInfo method, BeanValidationAnnotationsBuildItem bvAnnotations, HandlerType handlerType) { + HandlerDescriptor(MethodInfo method, BeanValidationAnnotationsBuildItem bvAnnotations, HandlerType handlerType, + String[] producedTypes) { this.method = method; this.validationAnnotations = bvAnnotations; this.handlerType = handlerType; Type returnType = method.returnType(); if (returnType.kind() == Kind.VOID) { - contentType = null; + payloadType = null; } else { if (returnType.name().equals(DotNames.UNI) || returnType.name().equals(DotNames.MULTI) || returnType.name().equals(DotNames.COMPLETION_STAGE)) { - contentType = returnType.asParameterizedType().arguments().get(0); + payloadType = returnType.asParameterizedType().arguments().get(0); } else { - contentType = returnType; + payloadType = returnType; } } + this.contentTypes = producedTypes; } Type getReturnType() { return method.returnType(); } - boolean isReturningVoid() { - return method.returnType().kind().equals(Type.Kind.VOID); - } - boolean isReturningUni() { return method.returnType().name().equals(DotNames.UNI); } @@ -55,6 +54,13 @@ boolean isReturningCompletionStage() { return method.returnType().name().equals(DotNames.COMPLETION_STAGE); } + public String getFirstContentType() { + if (contentTypes == null || contentTypes.length == 0) { + return null; + } + return contentTypes[0]; + } + /** * @return {@code true} if the method is annotated with a constraint or {@code @Valid} or any parameter has such kind of * annotation. @@ -86,28 +92,28 @@ boolean isProducedResponseValidated() { return false; } - Type getContentType() { - return contentType; + Type getPayloadType() { + return payloadType; } - boolean isContentTypeString() { - Type type = getContentType(); + boolean isPayloadString() { + Type type = getPayloadType(); if (type == null) { return false; } return type.name().equals(io.quarkus.arc.processor.DotNames.STRING); } - boolean isContentTypeBuffer() { - Type type = getContentType(); + boolean isPayloadTypeBuffer() { + Type type = getPayloadType(); if (type == null) { return false; } return type.name().equals(DotNames.BUFFER); } - boolean isContentTypeMutinyBuffer() { - Type type = getContentType(); + boolean isPayloadMutinyBuffer() { + Type type = getPayloadType(); if (type == null) { return false; } diff --git a/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/Methods.java b/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/Methods.java index 778f8f15b382c..7e9d70bc5fd4a 100644 --- a/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/Methods.java +++ b/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/Methods.java @@ -238,7 +238,7 @@ static void returnAndClose(BytecodeCreator creator) { } static boolean isNoContent(HandlerDescriptor descriptor) { - return descriptor.getContentType().name() + return descriptor.getPayloadType().name() .equals(DotName.createSimple(Void.class.getName())); } @@ -249,7 +249,7 @@ static ResultHandle createNpeBecauseItemIfNull(BytecodeCreator writer) { } static MethodDescriptor getEndMethodForContentType(HandlerDescriptor descriptor) { - if (descriptor.isContentTypeBuffer() || descriptor.isContentTypeMutinyBuffer()) { + if (descriptor.isPayloadTypeBuffer() || descriptor.isPayloadMutinyBuffer()) { return END_WITH_BUFFER; } return END_WITH_STRING; diff --git a/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/ReactiveRoutesProcessor.java b/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/ReactiveRoutesProcessor.java index 38343aad663c9..bc8aa1b8cec09 100644 --- a/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/ReactiveRoutesProcessor.java +++ b/extensions/reactive-routes/deployment/src/main/java/io/quarkus/vertx/web/deployment/ReactiveRoutesProcessor.java @@ -1,6 +1,10 @@ package io.quarkus.vertx.web.deployment; import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; +import static io.quarkus.vertx.web.ReactiveRoutes.APPLICATION_JSON; +import static io.quarkus.vertx.web.ReactiveRoutes.EVENT_STREAM; +import static io.quarkus.vertx.web.ReactiveRoutes.JSON_STREAM; +import static io.quarkus.vertx.web.ReactiveRoutes.ND_JSON; import static org.objectweb.asm.Opcodes.ACC_FINAL; import static org.objectweb.asm.Opcodes.ACC_PRIVATE; import static org.objectweb.asm.Opcodes.ACC_PUBLIC; @@ -376,7 +380,7 @@ public boolean test(String name) { if (routeHandler == null) { String handlerClass = generateHandler( new HandlerDescriptor(businessMethod.getMethod(), beanValidationAnnotations.orElse(null), - handlerType), + handlerType, produces), businessMethod.getBean(), businessMethod.getMethod(), classOutput, transformedAnnotations, routeString, reflectiveHierarchy, produces.length > 0 ? produces[0] : null, validatorAvailable, index); @@ -408,15 +412,15 @@ public boolean test(String name) { businessMethod.getMethod().declaringClass().name().withoutPackagePrefix() + "#" + businessMethod.getMethod().name() + "()", regex != null ? regex : path, - Arrays.stream(methods).collect(Collectors.joining(", ")), produces, - consumes)); + String.join(", ", methods), produces, consumes)); } } } for (AnnotatedRouteFilterBuildItem filterMethod : routeFilterBusinessMethods) { String handlerClass = generateHandler( - new HandlerDescriptor(filterMethod.getMethod(), beanValidationAnnotations.orElse(null), HandlerType.NORMAL), + new HandlerDescriptor(filterMethod.getMethod(), beanValidationAnnotations.orElse(null), HandlerType.NORMAL, + new String[0]), filterMethod.getBean(), filterMethod.getMethod(), classOutput, transformedAnnotations, filterMethod.getRouteFilter().toString(true), reflectiveHierarchy, null, validatorAvailable, index); reflectiveClasses.produce(new ReflectiveClassBuildItem(false, false, handlerClass)); @@ -752,8 +756,8 @@ void implementInvoke(HandlerDescriptor descriptor, BeanInfo bean, MethodInfo met block.assign(res, value); } CatchBlockCreator caught = block.addCatch(Methods.VALIDATION_CONSTRAINT_VIOLATION_EXCEPTION); - boolean forceJsonEncoding = !descriptor.isContentTypeString() && !descriptor.isContentTypeBuffer() - && !descriptor.isContentTypeMutinyBuffer(); + boolean forceJsonEncoding = !descriptor.isPayloadString() && !descriptor.isPayloadTypeBuffer() + && !descriptor.isPayloadMutinyBuffer(); caught.invokeStaticMethod( Methods.VALIDATION_HANDLE_VIOLATION_EXCEPTION, caught.getCaughtException(), invoke.getMethodParam(0), invoke.load(forceJsonEncoding)); @@ -775,33 +779,49 @@ void implementInvoke(HandlerDescriptor descriptor, BeanInfo bean, MethodInfo met ResultHandle sub = invoke.invokeInterfaceMethod(Methods.UNI_SUBSCRIBE, res); invoke.invokeVirtualMethod(Methods.UNI_SUBSCRIBE_WITH, sub, successCallback.getInstance(), failureCallback); - registerForReflection(descriptor.getContentType(), reflectiveHierarchy); + registerForReflection(descriptor.getPayloadType(), reflectiveHierarchy); } else if (descriptor.isReturningMulti()) { // 3 cases - regular multi vs. sse multi vs. json array multi, we need to check the type. - BranchResult isItSSE = invoke.ifTrue(invoke.invokeStaticMethod(Methods.IS_SSE, res)); - BytecodeCreator isSSE = isItSSE.trueBranch(); - handleSSEMulti(descriptor, isSSE, routingContext, res); - isSSE.close(); - - BytecodeCreator isNotSSE = isItSSE.falseBranch(); - BranchResult isItNdJson = isNotSSE.ifTrue(isNotSSE.invokeStaticMethod(Methods.IS_NDJSON, res)); - BytecodeCreator isNdjson = isItNdJson.trueBranch(); - handleNdjsonMulti(descriptor, isNdjson, routingContext, res); - isNdjson.close(); - - BytecodeCreator isNotNdjson = isItNdJson.falseBranch(); - BranchResult isItJson = isNotNdjson.ifTrue(isNotNdjson.invokeStaticMethod(Methods.IS_JSON_ARRAY, res)); - BytecodeCreator isJson = isItJson.trueBranch(); - handleJsonArrayMulti(descriptor, isJson, routingContext, res); - isJson.close(); - - BytecodeCreator isRegular = isItJson.falseBranch(); - handleRegularMulti(descriptor, isRegular, routingContext, res); - isRegular.close(); - isNotSSE.close(); - - registerForReflection(descriptor.getContentType(), reflectiveHierarchy); + // Let's check if we have a content type and use that one to decide which serialization we need to apply. + String contentType = descriptor.getFirstContentType(); + if (contentType != null) { + if (contentType.toLowerCase().startsWith(EVENT_STREAM)) { + handleSSEMulti(descriptor, invoke, routingContext, res); + } else if (contentType.toLowerCase().startsWith(APPLICATION_JSON)) { + handleJsonArrayMulti(descriptor, invoke, routingContext, res); + } else if (contentType.toLowerCase().startsWith(ND_JSON) + || contentType.toLowerCase().startsWith(JSON_STREAM)) { + handleNdjsonMulti(descriptor, invoke, routingContext, res); + } else { + handleRegularMulti(descriptor, invoke, routingContext, res); + } + } else { + // No content type, use the Multi Type - this approach does not work when using Quarkus security + // (as it wraps the produced Multi). + BranchResult isItSSE = invoke.ifTrue(invoke.invokeStaticMethod(Methods.IS_SSE, res)); + BytecodeCreator isSSE = isItSSE.trueBranch(); + handleSSEMulti(descriptor, isSSE, routingContext, res); + isSSE.close(); + + BytecodeCreator isNotSSE = isItSSE.falseBranch(); + BranchResult isItNdJson = isNotSSE.ifTrue(isNotSSE.invokeStaticMethod(Methods.IS_NDJSON, res)); + BytecodeCreator isNdjson = isItNdJson.trueBranch(); + handleNdjsonMulti(descriptor, isNdjson, routingContext, res); + isNdjson.close(); + + BytecodeCreator isNotNdjson = isItNdJson.falseBranch(); + BranchResult isItJson = isNotNdjson.ifTrue(isNotNdjson.invokeStaticMethod(Methods.IS_JSON_ARRAY, res)); + BytecodeCreator isJson = isItJson.trueBranch(); + handleJsonArrayMulti(descriptor, isJson, routingContext, res); + isJson.close(); + + BytecodeCreator isRegular = isItJson.falseBranch(); + handleRegularMulti(descriptor, isRegular, routingContext, res); + isRegular.close(); + isNotSSE.close(); + } + registerForReflection(descriptor.getPayloadType(), reflectiveHierarchy); } else if (descriptor.isReturningCompletionStage()) { // The method returns a CompletionStage - we write the provided item in the HTTP response // If the method returned null, we fail @@ -812,9 +832,9 @@ void implementInvoke(HandlerDescriptor descriptor, BeanInfo bean, MethodInfo met ResultHandle consumer = getWhenCompleteCallback(descriptor, invoke, routingContext, end, validatorField) .getInstance(); invoke.invokeInterfaceMethod(Methods.CS_WHEN_COMPLETE, res, consumer); - registerForReflection(descriptor.getContentType(), reflectiveHierarchy); + registerForReflection(descriptor.getPayloadType(), reflectiveHierarchy); - } else if (descriptor.getContentType() != null) { + } else if (descriptor.getPayloadType() != null) { // The method returns "something" in a synchronous manner, write it into the response ResultHandle response = invoke.invokeInterfaceMethod(Methods.RESPONSE, routingContext); // If the method returned null, we fail @@ -825,7 +845,7 @@ void implementInvoke(HandlerDescriptor descriptor, BeanInfo bean, MethodInfo met invoke.getThis()); invoke.invokeInterfaceMethod(end, response, content); - registerForReflection(descriptor.getContentType(), reflectiveHierarchy); + registerForReflection(descriptor.getPayloadType(), reflectiveHierarchy); } // Destroy dependent instance afterwards @@ -889,11 +909,11 @@ private void handleRegularMulti(HandlerDescriptor descriptor, BytecodeCreator wr if (Methods.isNoContent(descriptor)) { // Multi - so return a 204. writer.invokeStaticMethod(Methods.MULTI_SUBSCRIBE_VOID, res, rc); - } else if (descriptor.isContentTypeBuffer()) { + } else if (descriptor.isPayloadTypeBuffer()) { writer.invokeStaticMethod(Methods.MULTI_SUBSCRIBE_BUFFER, res, rc); - } else if (descriptor.isContentTypeMutinyBuffer()) { + } else if (descriptor.isPayloadMutinyBuffer()) { writer.invokeStaticMethod(Methods.MULTI_SUBSCRIBE_MUTINY_BUFFER, res, rc); - } else if (descriptor.isContentTypeString()) { + } else if (descriptor.isPayloadString()) { writer.invokeStaticMethod(Methods.MULTI_SUBSCRIBE_STRING, res, rc); } else { // Multi - encode to json. writer.invokeStaticMethod(Methods.MULTI_SUBSCRIBE_OBJECT, res, rc); @@ -913,11 +933,11 @@ private void handleSSEMulti(HandlerDescriptor descriptor, BytecodeCreator writer if (Methods.isNoContent(descriptor)) { // Multi - so return a 204. writer.invokeStaticMethod(Methods.MULTI_SUBSCRIBE_VOID, res, rc); - } else if (descriptor.isContentTypeBuffer()) { + } else if (descriptor.isPayloadTypeBuffer()) { writer.invokeStaticMethod(Methods.MULTI_SSE_SUBSCRIBE_BUFFER, res, rc); - } else if (descriptor.isContentTypeMutinyBuffer()) { + } else if (descriptor.isPayloadMutinyBuffer()) { writer.invokeStaticMethod(Methods.MULTI_SSE_SUBSCRIBE_MUTINY_BUFFER, res, rc); - } else if (descriptor.isContentTypeString()) { + } else if (descriptor.isPayloadString()) { writer.invokeStaticMethod(Methods.MULTI_SSE_SUBSCRIBE_STRING, res, rc); } else { // Multi - encode to json. writer.invokeStaticMethod(Methods.MULTI_SSE_SUBSCRIBE_OBJECT, res, rc); @@ -937,9 +957,9 @@ private void handleNdjsonMulti(HandlerDescriptor descriptor, BytecodeCreator wri if (Methods.isNoContent(descriptor)) { // Multi - so return a 204. writer.invokeStaticMethod(Methods.MULTI_SUBSCRIBE_VOID, res, rc); - } else if (descriptor.isContentTypeString()) { + } else if (descriptor.isPayloadString()) { writer.invokeStaticMethod(Methods.MULTI_NDJSON_SUBSCRIBE_STRING, res, rc); - } else if (descriptor.isContentTypeBuffer() || descriptor.isContentTypeMutinyBuffer()) { + } else if (descriptor.isPayloadTypeBuffer() || descriptor.isPayloadMutinyBuffer()) { writer.invokeStaticMethod(Methods.MULTI_JSON_FAIL, rc); } else { // Multi - encode to json. writer.invokeStaticMethod(Methods.MULTI_NDJSON_SUBSCRIBE_OBJECT, res, rc); @@ -960,9 +980,9 @@ private void handleJsonArrayMulti(HandlerDescriptor descriptor, BytecodeCreator if (Methods.isNoContent(descriptor)) { // Multi - so return a 204. writer.invokeStaticMethod(Methods.MULTI_JSON_SUBSCRIBE_VOID, res, rc); - } else if (descriptor.isContentTypeString()) { + } else if (descriptor.isPayloadString()) { writer.invokeStaticMethod(Methods.MULTI_JSON_SUBSCRIBE_STRING, res, rc); - } else if (descriptor.isContentTypeBuffer() || descriptor.isContentTypeMutinyBuffer()) { + } else if (descriptor.isPayloadTypeBuffer() || descriptor.isPayloadMutinyBuffer()) { writer.invokeStaticMethod(Methods.MULTI_JSON_FAIL, rc); } else { // Multi - encode to json. writer.invokeStaticMethod(Methods.MULTI_JSON_SUBSCRIBE_OBJECT, res, rc); @@ -1022,7 +1042,6 @@ private void handleJsonArrayMulti(HandlerDescriptor descriptor, BytecodeCreator * @param invoke the main bytecode writer * @param rc the reference to the routing context variable * @param end the end method to use - * @param response the reference to the response variable * @param validatorField the validator field if validation is enabled * @return the function creator */ @@ -1100,11 +1119,11 @@ private ResultHandle getUniOnFailureCallback(MethodCreator writer, ResultHandle private ResultHandle getContentToWrite(HandlerDescriptor descriptor, ResultHandle response, ResultHandle res, BytecodeCreator writer, FieldCreator validatorField, ResultHandle owner) { - if (descriptor.isContentTypeString() || descriptor.isContentTypeBuffer()) { + if (descriptor.isPayloadString() || descriptor.isPayloadTypeBuffer()) { return res; } - if (descriptor.isContentTypeMutinyBuffer()) { + if (descriptor.isPayloadMutinyBuffer()) { return writer.invokeVirtualMethod(Methods.MUTINY_GET_DELEGATE, res); } diff --git a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonMultiRouteTest.java b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonMultiRouteWithAsJsonArrayTest.java similarity index 99% rename from extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonMultiRouteTest.java rename to extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonMultiRouteWithAsJsonArrayTest.java index a1c9a5b8e77f3..477f91bc07983 100644 --- a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonMultiRouteTest.java +++ b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonMultiRouteWithAsJsonArrayTest.java @@ -17,7 +17,7 @@ import io.vertx.core.buffer.Buffer; import io.vertx.ext.web.RoutingContext; -public class JsonMultiRouteTest { +public class JsonMultiRouteWithAsJsonArrayTest { @RegisterExtension static final QuarkusUnitTest config = new QuarkusUnitTest() diff --git a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonMultiRouteWithContentTypeTest.java b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonMultiRouteWithContentTypeTest.java new file mode 100644 index 0000000000000..4345c6fc32871 --- /dev/null +++ b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonMultiRouteWithContentTypeTest.java @@ -0,0 +1,145 @@ +package io.quarkus.vertx.web.mutiny; + +import static io.restassured.RestAssured.when; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.vertx.web.ReactiveRoutes; +import io.quarkus.vertx.web.Route; +import io.smallrye.mutiny.Multi; +import io.vertx.core.buffer.Buffer; +import io.vertx.ext.web.RoutingContext; + +public class JsonMultiRouteWithContentTypeTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar.addClasses(SimpleBean.class)); + + @Test + public void testMultiRoute() { + when().get("/hello").then().statusCode(200) + .body(is("[\"Hello world!\"]")) + .header("content-type", "application/json"); + when().get("/hellos").then().statusCode(200) + .body(is("[\"hello\",\"world\",\"!\"]")) + .header("content-type", "application/json"); + when().get("/no-hello").then().statusCode(200).body(is("[]")) + .header("content-type", "application/json"); + // status already sent, but not the end of the array + when().get("/hello-and-fail").then().statusCode(200) + .body(containsString("[\"Hello\"")) + .body(not(containsString("]"))); + + when().get("/buffers").then().statusCode(500); + + when().get("/void").then().statusCode(200).body(is("[]")); + + when().get("/people").then().statusCode(200) + .body("size()", is(3)) + .body("[0].name", is("superman")) + .body("[1].name", is("batman")) + .body("[2].name", is("spiderman")) + .header("content-type", "application/json"); + + when().get("/people-content-type").then().statusCode(200) + .body("size()", is(3)) + .body("[0].name", is("superman")) + .body("[1].name", is("batman")) + .body("[2].name", is("spiderman")) + .header("content-type", "application/json;charset=utf-8"); + + when().get("/failure").then().statusCode(500).body(containsString("boom")); + when().get("/null").then().statusCode(500).body(containsString(NullPointerException.class.getName())); + when().get("/sync-failure").then().statusCode(500).body(containsString("boom")); + + } + + static class SimpleBean { + + @Route(path = "hello", produces = ReactiveRoutes.APPLICATION_JSON) + Multi hello() { + return Multi.createFrom().item("Hello world!"); + } + + @Route(path = "hellos", produces = ReactiveRoutes.APPLICATION_JSON) + Multi hellos() { + return Multi.createFrom().items("hello", "world", "!"); + } + + @Route(path = "no-hello", produces = ReactiveRoutes.APPLICATION_JSON) + Multi noHello() { + return Multi.createFrom().empty(); + } + + @Route(path = "hello-and-fail", produces = ReactiveRoutes.APPLICATION_JSON) + Multi helloAndFail() { + return Multi.createBy().concatenating().streams( + Multi.createFrom().item("Hello"), + Multi.createFrom().failure(new IOException("boom"))); + } + + @Route(path = "buffers", produces = ReactiveRoutes.APPLICATION_JSON) + Multi buffers() { + return Multi.createFrom() + .items(Buffer.buffer("Buffer"), Buffer.buffer(" Buffer"), Buffer.buffer(" Buffer.")); + } + + @Route(path = "void", produces = ReactiveRoutes.APPLICATION_JSON) + Multi multiVoid() { + return Multi.createFrom().range(0, 200) + .onItem().ignore(); + } + + @Route(path = "/people", produces = ReactiveRoutes.APPLICATION_JSON) + Multi people() { + return Multi.createFrom().items( + new Person("superman", 1), + new Person("batman", 2), + new Person("spiderman", 3)); + } + + @Route(path = "/people-content-type", produces = ReactiveRoutes.APPLICATION_JSON) + Multi peopleWithContentType(RoutingContext context) { + context.response().putHeader("content-type", "application/json;charset=utf-8"); + return Multi.createFrom().items( + new Person("superman", 1), + new Person("batman", 2), + new Person("spiderman", 3)); + } + + @Route(path = "/failure", produces = ReactiveRoutes.APPLICATION_JSON) + Multi fail() { + return Multi.createFrom().failure(new IOException("boom")); + } + + @Route(path = "/sync-failure", produces = ReactiveRoutes.APPLICATION_JSON) + Multi failSync() { + throw new IllegalStateException("boom"); + } + + @Route(path = "/null", produces = ReactiveRoutes.APPLICATION_JSON) + Multi uniNull() { + return null; + } + + } + + static class Person { + public String name; + public int id; + + public Person(String name, int id) { + this.name = name; + this.id = id; + } + } + +} diff --git a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonStreamMultiRouteWithContentTypeTest.java b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonStreamMultiRouteWithContentTypeTest.java new file mode 100644 index 0000000000000..274716926e2d7 --- /dev/null +++ b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/JsonStreamMultiRouteWithContentTypeTest.java @@ -0,0 +1,166 @@ +package io.quarkus.vertx.web.mutiny; + +import static io.quarkus.vertx.web.ReactiveRoutes.JSON_STREAM; +import static io.restassured.RestAssured.when; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasLength; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.vertx.web.Route; +import io.smallrye.mutiny.Multi; +import io.vertx.core.http.HttpHeaders; +import io.vertx.ext.web.RoutingContext; + +public class JsonStreamMultiRouteWithContentTypeTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar.addClasses(SimpleBean.class)); + + @Test + public void testNdjsonMultiRoute() { + when().get("/hello").then().statusCode(200) + .body(is("\"Hello world!\"\n")) + .header(HttpHeaders.CONTENT_TYPE.toString(), JSON_STREAM); + + when().get("/hellos").then().statusCode(200) + .body(containsString( + // @formatter:off + "\"hello\"\n" + + "\"world\"\n" + + "\"!\"\n")) + // @formatter:on + .header(HttpHeaders.CONTENT_TYPE.toString(), JSON_STREAM); + + when().get("/no-hello").then().statusCode(200).body(hasLength(0)) + .header(HttpHeaders.CONTENT_TYPE.toString(), JSON_STREAM); + + // We get the item followed by the exception + when().get("/hello-and-fail").then().statusCode(200) + .body(containsString("\"Hello\"")) + .body(not(containsString("boom"))); + + when().get("/void").then().statusCode(204).body(hasLength(0)); + + when().get("/people").then().statusCode(200) + .body(is( + // @formatter:off + "{\"name\":\"superman\",\"id\":1}\n" + + "{\"name\":\"batman\",\"id\":2}\n" + + "{\"name\":\"spiderman\",\"id\":3}\n" + )) + // @formatter:on + .header(HttpHeaders.CONTENT_TYPE.toString(), JSON_STREAM); + + when().get("/people-content-type").then().statusCode(200) + .body(is( + // @formatter:off + "{\"name\":\"superman\",\"id\":1}\n" + + "{\"name\":\"batman\",\"id\":2}\n" + + "{\"name\":\"spiderman\",\"id\":3}\n")) + // @formatter:on + .header(HttpHeaders.CONTENT_TYPE.toString(), is(JSON_STREAM + ";charset=utf-8")); + + when().get("/people-content-type-stream-json").then().statusCode(200) + .body(is( + // @formatter:off + "{\"name\":\"superman\",\"id\":1}\n" + + "{\"name\":\"batman\",\"id\":2}\n" + + "{\"name\":\"spiderman\",\"id\":3}\n")) + // @formatter:on + .header(HttpHeaders.CONTENT_TYPE.toString(), JSON_STREAM); + + when().get("/failure").then().statusCode(500).body(containsString("boom")); + when().get("/null").then().statusCode(500).body(containsString(NullPointerException.class.getName())); + when().get("/sync-failure").then().statusCode(500).body(containsString("boom")); + } + + static class SimpleBean { + + @Route(path = "hello", produces = JSON_STREAM) + Multi hello() { + return Multi.createFrom().item("Hello world!"); + } + + @Route(path = "hellos", produces = JSON_STREAM) + Multi hellos() { + return Multi.createFrom().items("hello", "world", "!"); + } + + @Route(path = "no-hello", produces = JSON_STREAM) + Multi noHello() { + return Multi.createFrom().empty(); + } + + @Route(path = "hello-and-fail", produces = JSON_STREAM) + Multi helloAndFail() { + return Multi.createBy().concatenating().streams( + Multi.createFrom().item("Hello"), + Multi.createFrom().failure(() -> new IOException("boom"))); + } + + @Route(path = "void", produces = JSON_STREAM) + Multi multiVoid() { + return Multi.createFrom().range(0, 200) + .onItem().ignore(); + } + + @Route(path = "/people", produces = JSON_STREAM) + Multi people() { + return Multi.createFrom().items( + new Person("superman", 1), + new Person("batman", 2), + new Person("spiderman", 3)); + } + + @Route(path = "/people-content-type", produces = JSON_STREAM) + Multi peopleWithContentType(RoutingContext context) { + context.response().putHeader(HttpHeaders.CONTENT_TYPE, JSON_STREAM + ";charset=utf-8"); + return Multi.createFrom().items( + new Person("superman", 1), + new Person("batman", 2), + new Person("spiderman", 3)); + } + + @Route(path = "/people-content-type-stream-json", produces = { JSON_STREAM }) + Multi peopleWithContentTypeNDJson() { + return Multi.createFrom().items( + new Person("superman", 1), + new Person("batman", 2), + new Person("spiderman", 3)); + } + + @Route(path = "/failure", produces = JSON_STREAM) + Multi fail() { + return Multi.createFrom().failure(new IOException("boom")); + } + + @Route(path = "/sync-failure", produces = JSON_STREAM) + Multi failSync() { + throw new IllegalStateException("boom"); + } + + @Route(path = "/null", produces = JSON_STREAM) + Multi uniNull() { + return null; + } + } + + static class Person { + public String name; + public int id; + + public Person(String name, int id) { + this.name = name; + this.id = id; + } + } + +} diff --git a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/NdjsonMultiRouteTest.java b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/NdjsonMultiRouteWithAsJsonStreamTest.java similarity index 99% rename from extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/NdjsonMultiRouteTest.java rename to extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/NdjsonMultiRouteWithAsJsonStreamTest.java index 7406073041c10..ffed353ff68f8 100644 --- a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/NdjsonMultiRouteTest.java +++ b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/NdjsonMultiRouteWithAsJsonStreamTest.java @@ -15,7 +15,7 @@ import io.vertx.core.http.HttpHeaders; import io.vertx.ext.web.RoutingContext; -public class NdjsonMultiRouteTest { +public class NdjsonMultiRouteWithAsJsonStreamTest { public static final String CONTENT_TYPE_NDJSON = "application/x-ndjson"; public static final String CONTENT_TYPE_STREAM_JSON = "application/stream+json"; diff --git a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/NdjsonMultiRouteWithContentTypeTest.java b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/NdjsonMultiRouteWithContentTypeTest.java new file mode 100644 index 0000000000000..d5b70d26c8022 --- /dev/null +++ b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/NdjsonMultiRouteWithContentTypeTest.java @@ -0,0 +1,167 @@ +package io.quarkus.vertx.web.mutiny; + +import static io.quarkus.vertx.web.ReactiveRoutes.JSON_STREAM; +import static io.quarkus.vertx.web.ReactiveRoutes.ND_JSON; +import static io.restassured.RestAssured.when; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasLength; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.vertx.web.Route; +import io.smallrye.mutiny.Multi; +import io.vertx.core.http.HttpHeaders; +import io.vertx.ext.web.RoutingContext; + +public class NdjsonMultiRouteWithContentTypeTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar.addClasses(SimpleBean.class)); + + @Test + public void testNdjsonMultiRoute() { + when().get("/hello").then().statusCode(200) + .body(is("\"Hello world!\"\n")) + .header(HttpHeaders.CONTENT_TYPE.toString(), ND_JSON); + + when().get("/hellos").then().statusCode(200) + .body(containsString( + // @formatter:off + "\"hello\"\n" + + "\"world\"\n" + + "\"!\"\n")) + // @formatter:on + .header(HttpHeaders.CONTENT_TYPE.toString(), ND_JSON); + + when().get("/no-hello").then().statusCode(200).body(hasLength(0)) + .header(HttpHeaders.CONTENT_TYPE.toString(), ND_JSON); + + // We get the item followed by the exception + when().get("/hello-and-fail").then().statusCode(200) + .body(containsString("\"Hello\"")) + .body(not(containsString("boom"))); + + when().get("/void").then().statusCode(204).body(hasLength(0)); + + when().get("/people").then().statusCode(200) + .body(is( + // @formatter:off + "{\"name\":\"superman\",\"id\":1}\n" + + "{\"name\":\"batman\",\"id\":2}\n" + + "{\"name\":\"spiderman\",\"id\":3}\n" + )) + // @formatter:on + .header(HttpHeaders.CONTENT_TYPE.toString(), ND_JSON); + + when().get("/people-content-type").then().statusCode(200) + .body(is( + // @formatter:off + "{\"name\":\"superman\",\"id\":1}\n" + + "{\"name\":\"batman\",\"id\":2}\n" + + "{\"name\":\"spiderman\",\"id\":3}\n")) + // @formatter:on + .header(HttpHeaders.CONTENT_TYPE.toString(), is(ND_JSON + ";charset=utf-8")); + + when().get("/people-content-type-stream-json").then().statusCode(200) + .body(is( + // @formatter:off + "{\"name\":\"superman\",\"id\":1}\n" + + "{\"name\":\"batman\",\"id\":2}\n" + + "{\"name\":\"spiderman\",\"id\":3}\n")) + // @formatter:on + .header(HttpHeaders.CONTENT_TYPE.toString(), JSON_STREAM); + + when().get("/failure").then().statusCode(500).body(containsString("boom")); + when().get("/null").then().statusCode(500).body(containsString(NullPointerException.class.getName())); + when().get("/sync-failure").then().statusCode(500).body(containsString("boom")); + } + + static class SimpleBean { + + @Route(path = "hello", produces = ND_JSON) + Multi hello() { + return Multi.createFrom().item("Hello world!"); + } + + @Route(path = "hellos", produces = ND_JSON) + Multi hellos() { + return Multi.createFrom().items("hello", "world", "!"); + } + + @Route(path = "no-hello", produces = ND_JSON) + Multi noHello() { + return Multi.createFrom().empty(); + } + + @Route(path = "hello-and-fail", produces = ND_JSON) + Multi helloAndFail() { + return Multi.createBy().concatenating().streams( + Multi.createFrom().item("Hello"), + Multi.createFrom().failure(() -> new IOException("boom"))); + } + + @Route(path = "void", produces = ND_JSON) + Multi multiVoid() { + return Multi.createFrom().range(0, 200) + .onItem().ignore(); + } + + @Route(path = "/people", produces = ND_JSON) + Multi people() { + return Multi.createFrom().items( + new Person("superman", 1), + new Person("batman", 2), + new Person("spiderman", 3)); + } + + @Route(path = "/people-content-type", produces = ND_JSON) + Multi peopleWithContentType(RoutingContext context) { + context.response().putHeader(HttpHeaders.CONTENT_TYPE, ND_JSON + ";charset=utf-8"); + return Multi.createFrom().items( + new Person("superman", 1), + new Person("batman", 2), + new Person("spiderman", 3)); + } + + @Route(path = "/people-content-type-stream-json", produces = { JSON_STREAM }) + Multi peopleWithContentTypeStreamJson() { + return Multi.createFrom().items( + new Person("superman", 1), + new Person("batman", 2), + new Person("spiderman", 3)); + } + + @Route(path = "/failure", produces = ND_JSON) + Multi fail() { + return Multi.createFrom().failure(new IOException("boom")); + } + + @Route(path = "/sync-failure", produces = ND_JSON) + Multi failSync() { + throw new IllegalStateException("boom"); + } + + @Route(path = "/null", produces = ND_JSON) + Multi uniNull() { + return null; + } + } + + static class Person { + public String name; + public int id; + + public Person(String name, int id) { + this.name = name; + this.id = id; + } + } + +} diff --git a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/SSEMultiRouteTest.java b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/SSEMultiRouteWithAsEventStreamTest.java similarity index 99% rename from extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/SSEMultiRouteTest.java rename to extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/SSEMultiRouteWithAsEventStreamTest.java index be5be59dff350..3275adbf08dd9 100644 --- a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/SSEMultiRouteTest.java +++ b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/SSEMultiRouteWithAsEventStreamTest.java @@ -15,7 +15,7 @@ import io.vertx.core.buffer.Buffer; import io.vertx.ext.web.RoutingContext; -public class SSEMultiRouteTest { +public class SSEMultiRouteWithAsEventStreamTest { @RegisterExtension static final QuarkusUnitTest config = new QuarkusUnitTest() diff --git a/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/SSEMultiRouteWithContentTypeTest.java b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/SSEMultiRouteWithContentTypeTest.java new file mode 100644 index 0000000000000..45f6e2427ce73 --- /dev/null +++ b/extensions/reactive-routes/deployment/src/test/java/io/quarkus/vertx/web/mutiny/SSEMultiRouteWithContentTypeTest.java @@ -0,0 +1,295 @@ +package io.quarkus.vertx.web.mutiny; + +import static io.quarkus.vertx.web.ReactiveRoutes.EVENT_STREAM; +import static io.restassured.RestAssured.when; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasLength; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.vertx.web.ReactiveRoutes; +import io.quarkus.vertx.web.Route; +import io.smallrye.mutiny.Multi; +import io.vertx.core.buffer.Buffer; +import io.vertx.ext.web.RoutingContext; + +public class SSEMultiRouteWithContentTypeTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar.addClasses(SimpleBean.class)); + + @Test + public void testSSEMultiRoute() { + when().get("/hello").then().statusCode(200) + .body(is("data: Hello world!\nid: 0\n\n")) + .header("content-type", "text/event-stream"); + + when().get("/hellos").then().statusCode(200) + .body(containsString( + // @formatter:off + "data: hello\nid: 0\n\n" + + "data: world\nid: 1\n\n" + + "data: !\nid: 2\n\n")) + // @formatter:on + .header("content-type", "text/event-stream"); + + when().get("/no-hello").then().statusCode(200).body(hasLength(0)) + .header("content-type", "text/event-stream"); + + // We get the item followed by the exception + when().get("/hello-and-fail").then().statusCode(200) + .body(containsString("id: 0")) + .body(not(containsString("boom"))); + + when().get("/buffer").then().statusCode(200) + .body(is("data: Buffer\nid: 0\n\n")) + .header("content-type", is("text/event-stream")); + + when().get("/buffers").then().statusCode(200) + .body(is("data: Buffer\nid: 0\n\ndata: Buffer\nid: 1\n\ndata: Buffer.\nid: 2\n\n")) + .header("content-type", is("text/event-stream")); + + when().get("/mutiny-buffer").then().statusCode(200) + .body(is("data: Buffer\nid: 0\n\ndata: Mutiny\nid: 1\n\n")) + .header("content-type", is("text/event-stream")); + + when().get("/void").then().statusCode(204).body(hasLength(0)); + + when().get("/people").then().statusCode(200) + .body(is( + // @formatter:off + "data: {\"name\":\"superman\",\"id\":1}\nid: 0\n\n" + + "data: {\"name\":\"batman\",\"id\":2}\nid: 1\n\n" + + "data: {\"name\":\"spiderman\",\"id\":3}\nid: 2\n\n")) + // @formatter:on + .header("content-type", is("text/event-stream")); + + when().get("/people-content-type").then().statusCode(200) + .body(is( + // @formatter:off + "data: {\"name\":\"superman\",\"id\":1}\nid: 0\n\n" + + "data: {\"name\":\"batman\",\"id\":2}\nid: 1\n\n" + + "data: {\"name\":\"spiderman\",\"id\":3}\nid: 2\n\n")) + // @formatter:on + .header("content-type", is("text/event-stream;charset=utf-8")); + + when().get("/people-as-event").then().statusCode(200) + .body(is( + // @formatter:off + "event: person\ndata: {\"name\":\"superman\",\"id\":1}\nid: 1\n\n" + + "event: person\ndata: {\"name\":\"batman\",\"id\":2}\nid: 2\n\n" + + "event: person\ndata: {\"name\":\"spiderman\",\"id\":3}\nid: 3\n\n")) + // @formatter:on + .header("content-type", is("text/event-stream")); + + when().get("/people-as-event-without-id").then().statusCode(200) + .body(is( + // @formatter:off + "event: person\ndata: {\"name\":\"superman\",\"id\":1}\nid: 0\n\n" + + "event: person\ndata: {\"name\":\"batman\",\"id\":2}\nid: 1\n\n" + + "event: person\ndata: {\"name\":\"spiderman\",\"id\":3}\nid: 2\n\n")) + // @formatter:on + .header("content-type", is("text/event-stream")); + + when().get("/people-as-event-without-event").then().statusCode(200) + .body(is( + // @formatter:off + "data: {\"name\":\"superman\",\"id\":1}\nid: 1\n\n" + + "data: {\"name\":\"batman\",\"id\":2}\nid: 2\n\n" + + "data: {\"name\":\"spiderman\",\"id\":3}\nid: 3\n\n")) + // @formatter:on + .header("content-type", is("text/event-stream")); + + when().get("/failure").then().statusCode(500).body(containsString("boom")); + when().get("/null").then().statusCode(500).body(containsString(NullPointerException.class.getName())); + when().get("/sync-failure").then().statusCode(500).body(containsString("boom")); + + } + + static class SimpleBean { + + @Route(path = "hello", produces = EVENT_STREAM) + Multi hello() { + return Multi.createFrom().item("Hello world!"); + } + + @Route(path = "hellos", produces = EVENT_STREAM) + Multi hellos() { + return Multi.createFrom().items("hello", "world", "!"); + } + + @Route(path = "no-hello", produces = EVENT_STREAM) + Multi noHello() { + return Multi.createFrom().empty(); + } + + @Route(path = "hello-and-fail", produces = EVENT_STREAM) + Multi helloAndFail() { + return Multi.createBy().concatenating().streams( + Multi.createFrom().item("Hello"), + Multi.createFrom().failure(() -> new IOException("boom"))); + } + + @Route(path = "buffer", produces = EVENT_STREAM) + Multi buffer() { + return Multi.createFrom().item(Buffer.buffer("Buffer")); + } + + @Route(path = "buffers", produces = EVENT_STREAM) + Multi buffers() { + return Multi.createFrom() + .items(Buffer.buffer("Buffer"), Buffer.buffer("Buffer"), Buffer.buffer("Buffer.")); + } + + @Route(path = "mutiny-buffer", produces = EVENT_STREAM) + Multi bufferMutiny() { + return Multi.createFrom().items(io.vertx.mutiny.core.buffer.Buffer.buffer("Buffer"), + io.vertx.mutiny.core.buffer.Buffer.buffer("Mutiny")); + } + + @Route(path = "void", produces = EVENT_STREAM) + Multi multiVoid() { + return Multi.createFrom().range(0, 200).onItem().ignore(); + } + + @Route(path = "/people", produces = EVENT_STREAM) + Multi people() { + return Multi.createFrom().items( + new Person("superman", 1), + new Person("batman", 2), + new Person("spiderman", 3)); + } + + @Route(path = "/people-as-event", produces = EVENT_STREAM) + Multi peopleAsEvent() { + return Multi.createFrom().items( + new PersonAsEvent("superman", 1), + new PersonAsEvent("batman", 2), + new PersonAsEvent("spiderman", 3)); + } + + @Route(path = "/people-as-event-without-id", produces = EVENT_STREAM) + Multi peopleAsEventWithoutId() { + return Multi.createFrom().items( + new PersonAsEventWithoutId("superman", 1), + new PersonAsEventWithoutId("batman", 2), + new PersonAsEventWithoutId("spiderman", 3)); + } + + @Route(path = "/people-as-event-without-event", produces = EVENT_STREAM) + Multi peopleAsEventWithoutEvent() { + return Multi.createFrom().items( + new PersonAsEventWithoutEvent("superman", 1), + new PersonAsEventWithoutEvent("batman", 2), + new PersonAsEventWithoutEvent("spiderman", 3)); + } + + @Route(path = "/people-content-type", produces = EVENT_STREAM) + Multi peopleWithContentType(RoutingContext context) { + context.response().putHeader("content-type", "text/event-stream;charset=utf-8"); + return Multi.createFrom().items( + new Person("superman", 1), + new Person("batman", 2), + new Person("spiderman", 3)); + } + + @Route(path = "/failure", produces = EVENT_STREAM) + Multi fail() { + return Multi.createFrom().failure(new IOException("boom")); + } + + @Route(path = "/sync-failure", produces = EVENT_STREAM) + Multi failSync() { + throw new IllegalStateException("boom"); + } + + @Route(path = "/null", produces = EVENT_STREAM) + Multi uniNull() { + return null; + } + + } + + static class Person { + public String name; + public int id; + + public Person(String name, int id) { + this.name = name; + this.id = id; + } + } + + static class PersonAsEvent implements ReactiveRoutes.ServerSentEvent { + public String name; + public int id; + + public PersonAsEvent(String name, int id) { + this.name = name; + this.id = id; + } + + @Override + public Person data() { + return new Person(name, id); + } + + @Override + public long id() { + return id; + } + + @Override + public String event() { + return "person"; + } + } + + static class PersonAsEventWithoutId implements ReactiveRoutes.ServerSentEvent { + public String name; + public int id; + + public PersonAsEventWithoutId(String name, int id) { + this.name = name; + this.id = id; + } + + @Override + public Person data() { + return new Person(name, id); + } + + @Override + public String event() { + return "person"; + } + } + + static class PersonAsEventWithoutEvent implements ReactiveRoutes.ServerSentEvent { + public String name; + public int id; + + public PersonAsEventWithoutEvent(String name, int id) { + this.name = name; + this.id = id; + } + + @Override + public Person data() { + return new Person(name, id); + } + + @Override + public long id() { + return id; + } + } + +} diff --git a/extensions/reactive-routes/runtime/src/main/java/io/quarkus/vertx/web/ReactiveRoutes.java b/extensions/reactive-routes/runtime/src/main/java/io/quarkus/vertx/web/ReactiveRoutes.java index f63b98e925038..54d69de93235c 100644 --- a/extensions/reactive-routes/runtime/src/main/java/io/quarkus/vertx/web/ReactiveRoutes.java +++ b/extensions/reactive-routes/runtime/src/main/java/io/quarkus/vertx/web/ReactiveRoutes.java @@ -12,6 +12,74 @@ */ public class ReactiveRoutes { + /** + * The content-type to use to indicate you want to produce an JSON Array response, such as in: + * + *
+     * {@code
+     * @Route(path = "/heroes", produces = ReactiveRoutes.APPLICATION_JSON_CONTENT_TYPE)
+     * Multi heroes() {
+     *     return Multi.createFrom().items(
+     *             new Person("superman", 1),
+     *             new Person("batman", 2),
+     *             new Person("spiderman", 3));
+     * }
+     * }
+     * 
+ * + * Note that the array is streamed object per object. + * Each object is written individually in the response, until the last one. + */ + public static final String APPLICATION_JSON = "application/json"; + + /** + * The content-type to use to indicate you want to produce a server-sent-event (SSE) stream response, such as in: + * + *
+     * {@code
+     * @Route(path = "/heroes", produces = ReactiveRoutes.EVENT_STREAM_CONTENT_TYPE)
+     * Multi heroes() {
+     *     return Multi.createFrom().items(
+     *             new Person("superman", 1),
+     *             new Person("batman", 2),
+     *             new Person("spiderman", 3));
+     * }
+     * }
+     * 
+ * + */ + public static final String EVENT_STREAM = "text/event-stream"; + + /** + * The content-type to use to indicate you want to produce NDJSON stream response, + * such as in: + * + *
+     * {@code
+     * @Route(path = "/heroes", produces = ReactiveRoutes.ND_JSON_CONTENT_TYPE)
+     * Multi heroes() {
+     *     return Multi.createFrom().items(
+     *             new Person("superman", 1),
+     *             new Person("batman", 2),
+     *             new Person("spiderman", 3));
+     * }
+     * }
+     * 
+ * + * NDJSON stands for Newline Delimited JSON. + * NDJSON is a convenient format for storing or streaming structured data that may be processed one record at a time: + *
    + *
  1. Line Separator is '\n',
  2. + *
  3. Each Line is a valid JSON value.
  4. + *
+ */ + public static final String ND_JSON = "application/x-ndjson"; + + /** + * A content-type providing the same output as {@link #ND_JSON}. + */ + public static final String JSON_STREAM = "application/stream+json"; + private ReactiveRoutes() { // Avoid direct instantiation. } @@ -48,7 +116,10 @@ private ReactiveRoutes() { * @param multi the multi to be written * @param the type of item, can be string, buffer, object or io.quarkus.vertx.web.ReactiveRoutes.ServerSentEvent * @return the wrapped multi + * @deprecated Instead, set the `produces` attribute of the {@link Route} annotation to + * {@link ReactiveRoutes#EVENT_STREAM} and return a plain Multi. */ + @Deprecated public static Multi asEventStream(Multi multi) { return new SSEMulti<>(Objects.requireNonNull(multi, "The passed multi must not be `null`")); } @@ -85,7 +156,10 @@ public static Multi asEventStream(Multi multi) { * @param multi the multi to be written * @param the type of item, can be string, object * @return the wrapped multi + * @deprecated Instead, set the `produces` attribute of the {@link Route} annotation to + * {@link ReactiveRoutes#ND_JSON} and return a plain Multi. */ + @Deprecated public static Multi asJsonStream(Multi multi) { return new NdjsonMulti<>(Objects.requireNonNull(multi, "The passed multi must not be `null`")); } @@ -118,7 +192,10 @@ public static Multi asJsonStream(Multi multi) { * @param multi the multi to be written * @param the type of item, can be string or object * @return the wrapped multi + * @deprecated Instead, set the `produces` attribute of the {@link Route} annotation to + * {@link ReactiveRoutes#APPLICATION_JSON} and return a plain Multi. */ + @Deprecated public static Multi asJsonArray(Multi multi) { return new JsonArrayMulti<>(Objects.requireNonNull(multi, "The passed multi must not be `null`")); } diff --git a/extensions/reactive-routes/runtime/src/main/java/io/quarkus/vertx/web/Route.java b/extensions/reactive-routes/runtime/src/main/java/io/quarkus/vertx/web/Route.java index 6becbb29ea2f3..db47168a2ddcd 100644 --- a/extensions/reactive-routes/runtime/src/main/java/io/quarkus/vertx/web/Route.java +++ b/extensions/reactive-routes/runtime/src/main/java/io/quarkus/vertx/web/Route.java @@ -131,12 +131,26 @@ enum HttpMethod { int order() default 0; /** - * Used for content-based routing. + * Used for content-based routing and stream serialization. *

- * If no {@code Content-Type} header is set then try to use the most acceptable content type. + * If no {@code Content-Type} header is set then try to use the most acceptable content-type. * * If the request does not contain an 'Accept' header and no content type is explicitly set in the * handler then the content type will be set to the first content type in the array. + * + * When a route returns a {@link io.smallrye.mutiny.Multi}, this attribute is used to define how that stream is + * serialized. In this case, accepted values are: + *

    + *
  • {@link ReactiveRoutes#APPLICATION_JSON} - Encode the response into a JSON Array, where each item is sent one by + * one,
  • + *
  • {@link ReactiveRoutes#EVENT_STREAM} - Encode the response as a stream of server-sent-events,
  • + *
  • {@link ReactiveRoutes#ND_JSON} or {@link ReactiveRoutes#JSON_STREAM} - Encode the response as JSON stream, + * when each item is sent one by one with a `\n` as delimiter between them
  • + *
+ * + * When this attribute is not set, and the route returns a {@link io.smallrye.mutiny.Multi}, no special serialization is + * applied. + * The items are sent one-by-one without delimiters. * * @see io.vertx.ext.web.Route#produces(String) * @see RoutingContext#getAcceptableContentType()