From 865e4aa444cffca1c66770bf3ef6a0e852bf4c68 Mon Sep 17 00:00:00 2001 From: Andrew Lindesay Date: Sun, 23 Nov 2025 12:17:11 +1300 Subject: [PATCH 1/3] Support for Jax-RS style "StreamingOutput" This commit only implements for the JEX server option. --- .../io/avaje/http/api/StreamingOutput.java | 24 +++++++++++++++++++ .../generator/jex/ControllerMethodWriter.java | 14 +++++++++++ 2 files changed, 38 insertions(+) create mode 100644 http-api/src/main/java/io/avaje/http/api/StreamingOutput.java diff --git a/http-api/src/main/java/io/avaje/http/api/StreamingOutput.java b/http-api/src/main/java/io/avaje/http/api/StreamingOutput.java new file mode 100644 index 00000000..31cf16a7 --- /dev/null +++ b/http-api/src/main/java/io/avaje/http/api/StreamingOutput.java @@ -0,0 +1,24 @@ +package io.avaje.http.api; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * An avaje {@link Controller} endpoint is able to use an instance of this interface + * as a return type. + * + *
{@code
+ * @Post("some_endpoint")
+ * @Produces("application/octet-stream")
+ * public StreamingOutput thumbnail(
+ *   InputStream inputStream,
+ *   @QueryParam(Constants.KEY_SIZE) @Min(1) @Max(Constants.MAX_SIZE) Integer size
+ * ) throws IOException {
+ *   return (os) -> os.write(new byte[] { 0x01, 0x02, 0x03 });
+ * }
+ * }
+ */ + +public interface StreamingOutput { + void write(OutputStream outputStream) throws IOException; +} diff --git a/http-generator-jex/src/main/java/io/avaje/http/generator/jex/ControllerMethodWriter.java b/http-generator-jex/src/main/java/io/avaje/http/generator/jex/ControllerMethodWriter.java index 1a12143a..92b4895c 100644 --- a/http-generator-jex/src/main/java/io/avaje/http/generator/jex/ControllerMethodWriter.java +++ b/http-generator-jex/src/main/java/io/avaje/http/generator/jex/ControllerMethodWriter.java @@ -107,6 +107,7 @@ enum ResponseMode { Jstachio, Templating, InputStream, + StreamingOutput, Other } @@ -117,6 +118,9 @@ ResponseMode responseMode() { if (isInputStream(method.returnType())) { return ResponseMode.InputStream; } + if (isStreamingOutput(method.returnType())) { + return ResponseMode.StreamingOutput; + } if (producesJson()) { return ResponseMode.Json; } @@ -136,6 +140,10 @@ private boolean isInputStream(TypeMirror type) { return isAssignable2Interface(type.toString(), "java.io.InputStream"); } + private boolean isStreamingOutput(TypeMirror type) { + return isAssignable2Interface(type.toString(), "io.avaje.http.api.StreamingOutput"); + } + private boolean producesJson() { return !"byte[]".equals(method.returnType().toString()) && !useJstachio @@ -294,11 +302,17 @@ private void writeContextReturn(ResponseMode responseMode, String resultVariable case Json -> writeJsonReturn(produces, indent); case Text -> writer.append("ctx.text(%s);", resultVariable); case Templating -> writer.append("ctx.html(%s);", resultVariable); + case StreamingOutput -> writeStreamingOutputReturn(produces, resultVariable, indent); default -> writer.append("ctx.contentType(\"%s\").write(%s);", produces, resultVariable); } writer.eol(); } + private void writeStreamingOutputReturn(String produces, String resultVariable, String indent) { + writer.append("ctx.contentType(\"%s\");", produces).eol(); + writer.append(indent).append("%s.write(ctx.outputStream());", resultVariable); + } + private void writeJsonReturn(String produces, String indent) { var uType = UType.parse(method.returnType()); boolean streaming = useJsonB && streamingContent(uType); From 3586a175fc4c0628deb96c2d0f27544d47c12bc9 Mon Sep 17 00:00:00 2001 From: Andrew Lindesay Date: Tue, 25 Nov 2025 09:47:12 +1300 Subject: [PATCH 2/3] Support for Jax-RS style "StreamingOutput" Add a test # Conflicts: # tests/test-jex/src/main/resources/public/openapi.json --- .../generator/jex/ControllerMethodWriter.java | 4 +- tests/pom.xml | 2 +- .../src/main/resources/public/openapi.json | 145 +++++++++++++----- .../src/main/resources/public/openapi.json | 58 ++++--- .../java/org/example/web/HelloController.java | 9 ++ .../src/main/resources/public/openapi.json | 82 +++++++--- .../org/example/web/HelloControllerTest.java | 16 ++ .../src/main/resources/public/openapi.json | 85 +++++----- 8 files changed, 273 insertions(+), 128 deletions(-) diff --git a/http-generator-jex/src/main/java/io/avaje/http/generator/jex/ControllerMethodWriter.java b/http-generator-jex/src/main/java/io/avaje/http/generator/jex/ControllerMethodWriter.java index 92b4895c..fbc415a0 100644 --- a/http-generator-jex/src/main/java/io/avaje/http/generator/jex/ControllerMethodWriter.java +++ b/http-generator-jex/src/main/java/io/avaje/http/generator/jex/ControllerMethodWriter.java @@ -310,7 +310,9 @@ private void writeContextReturn(ResponseMode responseMode, String resultVariable private void writeStreamingOutputReturn(String produces, String resultVariable, String indent) { writer.append("ctx.contentType(\"%s\");", produces).eol(); - writer.append(indent).append("%s.write(ctx.outputStream());", resultVariable); + writer.append(indent).append("java.io.OutputStream ctxOutputStream = ctx.outputStream();").eol(); + writer.append(indent).append("%s.write(ctxOutputStream);", resultVariable).eol(); + writer.append(indent).append("ctxOutputStream.flush();", resultVariable); } private void writeJsonReturn(String produces, String indent) { diff --git a/tests/pom.xml b/tests/pom.xml index 71ed2ff0..c6ebaeec 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -15,7 +15,7 @@ 6.0.1 3.27.6 2.20.1 - 3.3 + 3.4-RC1 12.0 4.3.2 6.7.0 diff --git a/tests/test-javalin-jsonb/src/main/resources/public/openapi.json b/tests/test-javalin-jsonb/src/main/resources/public/openapi.json index 11c8d142..34818240 100644 --- a/tests/test-javalin-jsonb/src/main/resources/public/openapi.json +++ b/tests/test-javalin-jsonb/src/main/resources/public/openapi.json @@ -520,7 +520,23 @@ "content" : { "application/x-www-form-urlencoded" : { "schema" : { - "$ref" : "#/components/schemas/HelloForm" + "type" : "object", + "properties" : { + "name" : { + "type" : "string", + "nullable" : false + }, + "email" : { + "type" : "string" + }, + "url" : { + "type" : "string" + }, + "startDate" : { + "type" : "string", + "format" : "date" + } + } } } }, @@ -579,7 +595,23 @@ "content" : { "application/x-www-form-urlencoded" : { "schema" : { - "$ref" : "#/components/schemas/HelloForm" + "type" : "object", + "properties" : { + "name" : { + "type" : "string", + "nullable" : false + }, + "email" : { + "type" : "string" + }, + "url" : { + "type" : "string" + }, + "startDate" : { + "type" : "string", + "format" : "date" + } + } } } }, @@ -1029,6 +1061,57 @@ } } }, + "/openapi/form" : { + "post" : { + "tags" : [ + + ], + "summary" : "", + "description" : "", + "parameters" : [ + { + "name" : "Head-String", + "in" : "header", + "schema" : { + "type" : "string" + } + } + ], + "requestBody" : { + "content" : { + "application/x-www-form-urlencoded" : { + "schema" : { + "type" : "object", + "properties" : { + "name" : { + "type" : "string" + }, + "email" : { + "type" : "string" + }, + "url" : { + "type" : "string" + } + } + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "description" : "", + "content" : { + "application/json" : { + "schema" : { + "type" : "string" + } + } + } + } + } + } + }, "/openapi/get" : { "get" : { "tags" : [ @@ -1653,11 +1736,31 @@ ], "summary" : "", "description" : "", + "parameters" : [ + { + "name" : "Head-String", + "in" : "header", + "schema" : { + "type" : "string" + } + } + ], "requestBody" : { "content" : { "application/x-www-form-urlencoded" : { "schema" : { - "$ref" : "#/components/schemas/MyForm" + "type" : "object", + "properties" : { + "name" : { + "type" : "string" + }, + "email" : { + "type" : "string" + }, + "url" : { + "type" : "string" + } + } } } }, @@ -2241,42 +2344,6 @@ } } }, - "HelloForm" : { - "required" : [ - "name" - ], - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "nullable" : false - }, - "email" : { - "type" : "string" - }, - "url" : { - "type" : "string" - }, - "startDate" : { - "type" : "string", - "format" : "date" - } - } - }, - "MyForm" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string" - }, - "email" : { - "type" : "string" - }, - "url" : { - "type" : "string" - } - } - }, "Person" : { "type" : "object", "properties" : { diff --git a/tests/test-javalin/src/main/resources/public/openapi.json b/tests/test-javalin/src/main/resources/public/openapi.json index cf36d6a3..28081a57 100644 --- a/tests/test-javalin/src/main/resources/public/openapi.json +++ b/tests/test-javalin/src/main/resources/public/openapi.json @@ -490,7 +490,23 @@ "content" : { "application/x-www-form-urlencoded" : { "schema" : { - "$ref" : "#/components/schemas/HelloForm" + "type" : "object", + "properties" : { + "name" : { + "type" : "string", + "nullable" : false + }, + "email" : { + "type" : "string" + }, + "url" : { + "type" : "string" + }, + "startDate" : { + "type" : "string", + "format" : "date" + } + } } } }, @@ -549,7 +565,23 @@ "content" : { "application/x-www-form-urlencoded" : { "schema" : { - "$ref" : "#/components/schemas/HelloForm" + "type" : "object", + "properties" : { + "name" : { + "type" : "string", + "nullable" : false + }, + "email" : { + "type" : "string" + }, + "url" : { + "type" : "string" + }, + "startDate" : { + "type" : "string", + "format" : "date" + } + } } } }, @@ -874,28 +906,6 @@ "format" : "date-time" } } - }, - "HelloForm" : { - "required" : [ - "name" - ], - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "nullable" : false - }, - "email" : { - "type" : "string" - }, - "url" : { - "type" : "string" - }, - "startDate" : { - "type" : "string", - "format" : "date" - } - } } } } diff --git a/tests/test-jex/src/main/java/org/example/web/HelloController.java b/tests/test-jex/src/main/java/org/example/web/HelloController.java index f3b892ed..fdb3c65e 100644 --- a/tests/test-jex/src/main/java/org/example/web/HelloController.java +++ b/tests/test-jex/src/main/java/org/example/web/HelloController.java @@ -10,6 +10,7 @@ import io.avaje.http.api.Produces; import io.avaje.http.api.Put; import io.avaje.http.api.Valid; +import io.avaje.http.api.StreamingOutput; import io.avaje.jex.http.Context; // @Roles(AppRoles.BASIC_USER) @@ -77,4 +78,12 @@ String testBigInt(BigInteger val) { String rawJsonString() { return "{\"key\": 42 }"; } + + @Get("streamBytes") + @Produces(value = "text/html", statusCode = 200) + StreamingOutput streamBytes() { + return outputStream -> outputStream.write(new byte[]{ + 0x41, 0x76, 0x61, 0x6a, 0x65 + }); + } } diff --git a/tests/test-jex/src/main/resources/public/openapi.json b/tests/test-jex/src/main/resources/public/openapi.json index 8017f8b3..3789d95d 100644 --- a/tests/test-jex/src/main/resources/public/openapi.json +++ b/tests/test-jex/src/main/resources/public/openapi.json @@ -590,7 +590,23 @@ "content" : { "application/x-www-form-urlencoded" : { "schema" : { - "$ref" : "#/components/schemas/HelloForm" + "type" : "object", + "properties" : { + "name" : { + "type" : "string", + "nullable" : false + }, + "email" : { + "type" : "string" + }, + "url" : { + "type" : "string" + }, + "startDate" : { + "type" : "string", + "format" : "date" + } + } } } }, @@ -649,7 +665,23 @@ "content" : { "application/x-www-form-urlencoded" : { "schema" : { - "$ref" : "#/components/schemas/HelloForm" + "type" : "object", + "properties" : { + "name" : { + "type" : "string", + "nullable" : false + }, + "email" : { + "type" : "string" + }, + "url" : { + "type" : "string" + }, + "startDate" : { + "type" : "string", + "format" : "date" + } + } } } }, @@ -1345,6 +1377,27 @@ } } }, + "/streamBytes" : { + "get" : { + "tags" : [ + + ], + "summary" : "", + "description" : "", + "responses" : { + "200" : { + "description" : "", + "content" : { + "text/html" : { + "schema" : { + "$ref" : "#/components/schemas/StreamingOutput" + } + } + } + } + } + } + }, "/test/enumQuery" : { "get" : { "tags" : [ @@ -1615,28 +1668,6 @@ "HelloDto>" : { "type" : "object" }, - "HelloForm" : { - "required" : [ - "name" - ], - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "nullable" : false - }, - "email" : { - "type" : "string" - }, - "url" : { - "type" : "string" - }, - "startDate" : { - "type" : "string", - "format" : "date" - } - } - }, "HelloWorld" : { "type" : "object", "properties" : { @@ -1683,6 +1714,9 @@ } } }, + "StreamingOutput" : { + "type" : "object" + }, "String>" : { "type" : "object" }, diff --git a/tests/test-jex/src/test/java/org/example/web/HelloControllerTest.java b/tests/test-jex/src/test/java/org/example/web/HelloControllerTest.java index ab52ad3b..f4b13785 100644 --- a/tests/test-jex/src/test/java/org/example/web/HelloControllerTest.java +++ b/tests/test-jex/src/test/java/org/example/web/HelloControllerTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import java.net.http.HttpResponse; +import java.util.Optional; import org.junit.jupiter.api.Test; @@ -205,4 +206,19 @@ void optionalQueryParamString_withValue() { assertThat(res.body()).isEqualTo("takesOptionalString-Optional[foo]"); } + @Test + void streamBytesTest() { + HttpResponse res = client.request() + .path("streamBytes") + .GET() + .asString(); + + Optional contentTypeHeaderValueOptional = res.headers().firstValue("Content-Type"); + + assertThat(contentTypeHeaderValueOptional.isPresent()).isEqualTo(true); + assertThat(contentTypeHeaderValueOptional.get()).isEqualTo("text/html"); + assertThat(res.body()).isEqualTo("Avaje"); + assertThat(res.statusCode()).isEqualTo(200); + } + } diff --git a/tests/test-sigma/src/main/resources/public/openapi.json b/tests/test-sigma/src/main/resources/public/openapi.json index 01e736b6..c94620ea 100644 --- a/tests/test-sigma/src/main/resources/public/openapi.json +++ b/tests/test-sigma/src/main/resources/public/openapi.json @@ -520,7 +520,23 @@ "content" : { "application/x-www-form-urlencoded" : { "schema" : { - "$ref" : "#/components/schemas/HelloForm" + "type" : "object", + "properties" : { + "name" : { + "type" : "string", + "nullable" : false + }, + "email" : { + "type" : "string" + }, + "url" : { + "type" : "string" + }, + "startDate" : { + "type" : "string", + "format" : "date" + } + } } } }, @@ -579,7 +595,23 @@ "content" : { "application/x-www-form-urlencoded" : { "schema" : { - "$ref" : "#/components/schemas/HelloForm" + "type" : "object", + "properties" : { + "name" : { + "type" : "string", + "nullable" : false + }, + "email" : { + "type" : "string" + }, + "url" : { + "type" : "string" + }, + "startDate" : { + "type" : "string", + "format" : "date" + } + } } } }, @@ -1516,7 +1548,18 @@ "content" : { "application/x-www-form-urlencoded" : { "schema" : { - "$ref" : "#/components/schemas/MyForm" + "type" : "object", + "properties" : { + "name" : { + "type" : "string" + }, + "email" : { + "type" : "string" + }, + "url" : { + "type" : "string" + } + } } } }, @@ -2026,28 +2069,6 @@ } } }, - "HelloForm" : { - "required" : [ - "name" - ], - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "nullable" : false - }, - "email" : { - "type" : "string" - }, - "url" : { - "type" : "string" - }, - "startDate" : { - "type" : "string", - "format" : "date" - } - } - }, "HelloWorld" : { "type" : "object", "properties" : { @@ -2076,20 +2097,6 @@ } } }, - "MyForm" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string" - }, - "email" : { - "type" : "string" - }, - "url" : { - "type" : "string" - } - } - }, "Person" : { "type" : "object", "properties" : { From 593bb6003a8a9093cc59b6e3a630dc00e6b34bf5 Mon Sep 17 00:00:00 2001 From: Andrew Lindesay Date: Wed, 26 Nov 2025 23:42:20 +1300 Subject: [PATCH 3/3] Support for Jax-RS style "StreamingOutput" Swich back to the 3.3 of the avaje-jex and closing the `OutputStream` instead of flushing. --- .../io/avaje/http/generator/jex/ControllerMethodWriter.java | 6 +++--- tests/pom.xml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/http-generator-jex/src/main/java/io/avaje/http/generator/jex/ControllerMethodWriter.java b/http-generator-jex/src/main/java/io/avaje/http/generator/jex/ControllerMethodWriter.java index fbc415a0..803a1dcf 100644 --- a/http-generator-jex/src/main/java/io/avaje/http/generator/jex/ControllerMethodWriter.java +++ b/http-generator-jex/src/main/java/io/avaje/http/generator/jex/ControllerMethodWriter.java @@ -310,9 +310,9 @@ private void writeContextReturn(ResponseMode responseMode, String resultVariable private void writeStreamingOutputReturn(String produces, String resultVariable, String indent) { writer.append("ctx.contentType(\"%s\");", produces).eol(); - writer.append(indent).append("java.io.OutputStream ctxOutputStream = ctx.outputStream();").eol(); - writer.append(indent).append("%s.write(ctxOutputStream);", resultVariable).eol(); - writer.append(indent).append("ctxOutputStream.flush();", resultVariable); + writer.append(indent).append("try (java.io.OutputStream ctxOutputStream = ctx.outputStream()) {").eol(); + writer.append(indent).append(indent).append("%s.write(ctxOutputStream);", resultVariable).eol(); + writer.append(indent).append("}", resultVariable); } private void writeJsonReturn(String produces, String indent) { diff --git a/tests/pom.xml b/tests/pom.xml index c6ebaeec..71ed2ff0 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -15,7 +15,7 @@ 6.0.1 3.27.6 2.20.1 - 3.4-RC1 + 3.3 12.0 4.3.2 6.7.0