Skip to content

Commit 5cb22cb

Browse files
authored
Streaming output for helidon/javalin (#679)
1 parent 33c1c29 commit 5cb22cb

File tree

8 files changed

+159
-54
lines changed

8 files changed

+159
-54
lines changed

http-generator-helidon/src/main/java/io/avaje/http/generator/helidon/nima/ControllerMethodWriter.java

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
*/
1818
final class ControllerMethodWriter {
1919

20+
private static final String FILTER_CHAIN = "FilterChain";
2021
private static final Map<Integer,String> statusMap = new HashMap<>();
2122
static {
2223
statusMap.put(200, "OK_200");
@@ -78,7 +79,7 @@ final class ControllerMethodWriter {
7879
}
7980

8081
private void validateMethod() {
81-
if (method.params().stream().map(MethodParam::shortType).noneMatch("FilterChain"::equals)) {
82+
if (method.params().stream().map(MethodParam::shortType).noneMatch(FILTER_CHAIN::equals)) {
8283
logError(method.element(), "Filters must contain a FilterChain Parameter");
8384
}
8485
}
@@ -223,7 +224,7 @@ void writeHandler(boolean requestScoped) {
223224
final var param = params.get(i);
224225
if (isAssignable2Interface(param.utype().mainType(), "java.lang.Exception")) {
225226
writer.append("ex");
226-
} else if ("FilterChain".equals(param.shortType())) {
227+
} else if (FILTER_CHAIN.equals(param.shortType())) {
227228
writer.append("chain");
228229
} else {
229230
param.buildParamName(writer);
@@ -242,34 +243,42 @@ void writeHandler(boolean requestScoped) {
242243
writer.append(" res.status(NO_CONTENT_204).send();").eol();
243244
writer.append(" } else {").eol();
244245
}
246+
final var uType = UType.parse(method.returnType());
245247
String indent = includeNoContent ? " " : " ";
246-
if (responseMode == ResponseMode.Templating) {
247-
writer.append(indent).append("var content = renderer.render(result);").eol();
248-
if (withContentCache) {
249-
writer.append(indent).append("contentCache.contentPut(key, content);").eol();
250-
}
251-
writeContextReturn(indent);
252-
writer.append(indent).append("res.send(content);").eol();
248+
switch (responseMode) {
249+
case ResponseMode.Templating -> {
250+
writer.append(indent).append("var content = renderer.render(result);").eol();
251+
if (withContentCache) {
252+
writer.append(indent).append("contentCache.contentPut(key, content);").eol();
253+
}
254+
writeContextReturn(indent);
255+
writer.append(indent).append("res.send(content);").eol();
253256

254-
} else if (responseMode == ResponseMode.Jstachio) {
255-
var renderer = ProcessingContext.jstacheRenderer(method.returnType());
256-
writer.append(indent).append("var content = %s(result);", renderer).eol();
257-
writeContextReturn(indent);
258-
writer.append(indent).append("res.send(content);").eol();
257+
}
258+
case ResponseMode.Jstachio -> {
259+
var renderer = ProcessingContext.jstacheRenderer(method.returnType());
260+
writer.append(indent).append("var content = %s(result);", renderer).eol();
261+
writeContextReturn(indent);
262+
writer.append(indent).append("res.send(content);").eol();
259263

260-
} else {
261-
final var uType = UType.parse(method.returnType());
262-
writeContextReturn(indent, streamingResponse(uType));
263-
if (responseMode == ResponseMode.InputStream) {
264-
writer.append(indent).append("result.transferTo(res.outputStream());", uType.shortName()).eol();
265-
} else if (responseMode == ResponseMode.Json) {
266-
if (returnTypeString()) {
267-
writer.append(indent).append("res.send(result); // send raw JSON").eol();
264+
}
265+
case ResponseMode.StreamingOutput -> {
266+
writeContextReturn(indent, streamingResponse(uType));
267+
writeStreamingOutputReturn(indent);
268+
}
269+
default -> {
270+
writeContextReturn(indent, streamingResponse(uType));
271+
if (responseMode == ResponseMode.InputStream) {
272+
writer.append(indent).append("result.transferTo(res.outputStream());", uType.shortName()).eol();
273+
} else if (responseMode == ResponseMode.Json) {
274+
if (returnTypeString()) {
275+
writer.append(indent).append("res.send(result); // send raw JSON").eol();
276+
} else {
277+
writer.append(indent).append("%sJsonType.toJson(result, JsonOutput.of(res));", uType.shortName()).eol();
278+
}
268279
} else {
269-
writer.append(indent).append("%sJsonType.toJson(result, JsonOutput.of(res));", uType.shortName()).eol();
280+
writer.append(indent).append("res.send(result);").eol();
270281
}
271-
} else {
272-
writer.append(indent).append("res.send(result);").eol();
273282
}
274283
}
275284
if (includeNoContent) {
@@ -279,8 +288,14 @@ void writeHandler(boolean requestScoped) {
279288
writer.append(" }").eol().eol();
280289
}
281290

291+
private void writeStreamingOutputReturn(String indent) {
292+
writer.append(indent).append("try (var responseOutputStream = res.outputStream()) {").eol();
293+
writer.append(indent).append(indent).append("result.write(responseOutputStream);").eol();
294+
writer.append(indent).append("}").eol();
295+
}
296+
282297
private static boolean streamingResponse(UType uType) {
283-
return uType.mainType().equals("java.util.stream.Stream");
298+
return "java.util.stream.Stream".equals(uType.mainType());
284299
}
285300

286301
enum ResponseMode {
@@ -289,6 +304,7 @@ enum ResponseMode {
289304
Jstachio,
290305
Templating,
291306
InputStream,
307+
StreamingOutput,
292308
Other
293309
}
294310

@@ -299,6 +315,9 @@ ResponseMode responseMode() {
299315
if (isInputStream(method.returnType())) {
300316
return ResponseMode.InputStream;
301317
}
318+
if (isStreamingOutput(method.returnType())) {
319+
return ResponseMode.StreamingOutput;
320+
}
302321
if (producesJson()) {
303322
return ResponseMode.Json;
304323
}
@@ -323,7 +342,7 @@ private boolean useTemplating() {
323342

324343
private static boolean isExceptionOrFilterChain(MethodParam param) {
325344
return isAssignable2Interface(param.utype().mainType(), "java.lang.Exception")
326-
|| "FilterChain".equals(param.shortType());
345+
|| FILTER_CHAIN.equals(param.shortType());
327346
}
328347

329348
private boolean isInputStream(TypeMirror type) {
@@ -420,4 +439,8 @@ private String lookupStatusCode(int statusCode) {
420439
public void buildApiDocumentation() {
421440
method.buildApiDoc();
422441
}
442+
443+
private boolean isStreamingOutput(TypeMirror type) {
444+
return isAssignable2Interface(type.toString(), "io.avaje.http.api.StreamingOutput");
445+
}
423446
}

http-generator-javalin/src/main/java/io/avaje/http/generator/javalin/ControllerMethodWriter.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,19 @@ private void writeContextReturn(final String resultVariableName) {
159159
produces = MediaType.TEXT_HTML.getValue();
160160
}
161161

162+
var uType = UType.parse(method.returnType());
163+
162164
boolean applicationJson = produces == null || MediaType.APPLICATION_JSON.getValue().equalsIgnoreCase(produces);
163-
if (applicationJson || JsonBUtil.isJsonMimeType(produces)) {
165+
166+
if (isAssignable2Interface(uType.mainType(), "io.avaje.http.api.StreamingOutput")) {
167+
writer.append(" ctx.contentType(\"%s\");", produces).eol();
168+
writer.append(" try (var ctxOutputStream = ctx.outputStream()) {").eol();
169+
writer.append(" %s.write(ctxOutputStream);", resultVariableName).eol();
170+
writer.append(" } catch (java.io.IOException e) {").eol();
171+
writer.append(" throw new java.io.UncheckedIOException(e);").eol();
172+
writer.append(" }").eol();
173+
} else if (applicationJson || JsonBUtil.isJsonMimeType(produces)) {
164174
if (useJsonB) {
165-
var uType = UType.parse(method.returnType());
166175
final boolean isfuture = "java.util.concurrent.CompletableFuture".equals(uType.mainType());
167176
if (isfuture || method.isErrorMethod()) {
168177
if (isfuture) {

http-generator-jex/src/main/java/io/avaje/http/generator/jex/ControllerMethodWriter.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -310,9 +310,9 @@ private void writeContextReturn(ResponseMode responseMode, String resultVariable
310310

311311
private void writeStreamingOutputReturn(String produces, String resultVariable, String indent) {
312312
writer.append("ctx.contentType(\"%s\");", produces).eol();
313-
writer.append(indent).append("try (java.io.OutputStream ctxOutputStream = ctx.outputStream()) {").eol();
313+
writer.append(indent).append("try (var ctxOutputStream = ctx.outputStream()) {").eol();
314314
writer.append(indent).append(indent).append("%s.write(ctxOutputStream);", resultVariable).eol();
315-
writer.append(indent).append("}", resultVariable);
315+
writer.append(indent).append("}");
316316
}
317317

318318
private void writeJsonReturn(String produces, String indent) {
@@ -340,7 +340,7 @@ private void writeJsonReturn(String produces, String indent) {
340340
}
341341

342342
private static boolean streamingContent(UType uType) {
343-
return uType.mainType().equals("java.util.stream.Stream");
343+
return "java.util.stream.Stream".equals(uType.mainType());
344344
}
345345

346346
private static boolean isExceptionOrFilterChain(MethodParam param) {

tests/test-javalin-jsonb/src/main/java/org/example/myapp/web/HelloController.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import java.util.concurrent.Executors;
1010

1111
import org.example.myapp.service.MyService;
12+
import org.example.myapp.web.other.Foo;
1213

1314
import io.avaje.http.api.BeanParam;
1415
import io.avaje.http.api.Controller;
@@ -21,11 +22,11 @@
2122
import io.avaje.http.api.Post;
2223
import io.avaje.http.api.Produces;
2324
import io.avaje.http.api.QueryParam;
25+
import io.avaje.http.api.StreamingOutput;
2426
import io.avaje.http.api.Valid;
2527
import io.javalin.http.Context;
2628
import io.swagger.v3.oas.annotations.Hidden;
2729
import jakarta.inject.Inject;
28-
import org.example.myapp.web.other.Foo;
2930

3031
/**
3132
* Hello resource manager.
@@ -152,7 +153,7 @@ CompletableFuture<List<HelloDto>> getAllAsync() {
152153
// This also helps ensure that we aren't just getting lucky with timings.
153154
try {
154155
Thread.sleep(10L);
155-
} catch (InterruptedException e) {
156+
} catch (final InterruptedException e) {
156157
throw new RuntimeException(e);
157158
}
158159

@@ -190,4 +191,12 @@ String controlStatusCode(Context ctx) {
190191
String takesNestedEnum(Foo.NestedEnum myEnum) {
191192
return "takesNestedEnum-" + myEnum;
192193
}
194+
195+
@Get("streamBytes")
196+
@Produces(value = "text/html", statusCode = 200)
197+
StreamingOutput streamBytes() {
198+
return outputStream -> outputStream.write(new byte[]{
199+
0x41, 0x76, 0x61, 0x6a, 0x65
200+
});
201+
}
193202
}

tests/test-javalin-jsonb/src/main/resources/public/openapi.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,27 @@
678678
}
679679
}
680680
},
681+
"/hello/streamBytes" : {
682+
"get" : {
683+
"tags" : [
684+
685+
],
686+
"summary" : "",
687+
"description" : "",
688+
"responses" : {
689+
"200" : {
690+
"description" : "",
691+
"content" : {
692+
"text/html" : {
693+
"schema" : {
694+
"$ref" : "#/components/schemas/StreamingOutput"
695+
}
696+
}
697+
}
698+
}
699+
}
700+
}
701+
},
681702
"/hello/takesNestedEnum" : {
682703
"get" : {
683704
"tags" : [
@@ -2357,6 +2378,9 @@
23572378
}
23582379
}
23592380
},
2381+
"StreamingOutput" : {
2382+
"type" : "object"
2383+
},
23602384
"String,?>" : {
23612385
"type" : "object"
23622386
},

tests/test-javalin-jsonb/src/test/java/org/example/myapp/HelloControllerTest.java

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,28 @@
11
package org.example.myapp;
22

3-
import io.avaje.http.client.*;
4-
import io.restassured.common.mapper.TypeRef;
5-
import io.restassured.http.ContentType;
6-
import io.restassured.response.Response;
7-
import org.example.myapp.web.HelloDto;
8-
import org.junit.jupiter.api.Test;
9-
10-
import java.net.http.HttpResponse;
11-
import java.util.List;
12-
import java.util.Map;
13-
143
import static io.restassured.RestAssured.get;
154
import static io.restassured.RestAssured.given;
165
import static org.assertj.core.api.Assertions.assertThat;
176
import static org.hamcrest.Matchers.equalTo;
187
import static org.junit.jupiter.api.Assertions.assertEquals;
198
import static org.junit.jupiter.api.Assertions.assertNotNull;
209

10+
import java.net.http.HttpResponse;
11+
import java.util.List;
12+
import java.util.Optional;
13+
14+
import org.example.myapp.web.HelloDto;
15+
import org.junit.jupiter.api.Test;
16+
17+
import io.avaje.http.client.BodyReader;
18+
import io.avaje.http.client.BodyWriter;
19+
import io.avaje.http.client.HttpClient;
20+
import io.avaje.http.client.HttpException;
21+
import io.avaje.http.client.JacksonBodyAdapter;
22+
import io.restassured.common.mapper.TypeRef;
23+
import io.restassured.http.ContentType;
24+
import io.restassured.response.Response;
25+
2126
class HelloControllerTest extends BaseWebTest {
2227

2328
final HttpClient client;
@@ -45,7 +50,7 @@ void hello() {
4550
@Test
4651
void hello2() {
4752

48-
TypeRef<List<HelloDto>> listDto = new TypeRef<>() { };
53+
final TypeRef<List<HelloDto>> listDto = new TypeRef<>() { };
4954
final List<HelloDto> beans = given()
5055
.get(baseUrl + "/hello")
5156
.then()
@@ -64,7 +69,7 @@ void hello2() {
6469

6570
@Test
6671
void helloAsyncRequestHandling() {
67-
TypeRef<List<HelloDto>> listDto = new TypeRef<>() { };
72+
final TypeRef<List<HelloDto>> listDto = new TypeRef<>() { };
6873
final List<HelloDto> beans = given()
6974
.get(baseUrl + "/hello/async")
7075
.then()
@@ -106,7 +111,7 @@ void getWithPathParamAndQueryParam() {
106111

107112
@Test
108113
void postIt() {
109-
HelloDto dto = new HelloDto(12, "rob", "other");
114+
final HelloDto dto = new HelloDto(12, "rob", "other");
110115

111116
given().body(dto).post(baseUrl + "/hello")
112117
.then()
@@ -129,7 +134,7 @@ void postIt() {
129134

130135
@Test
131136
void saveBean() {
132-
HelloDto dto = new HelloDto(12, "rob", "other");
137+
final HelloDto dto = new HelloDto(12, "rob", "other");
133138

134139
given().body(dto).post(baseUrl + "/hello/savebean/foo")
135140
.then()
@@ -217,7 +222,7 @@ void postForm_validation_expect_badRequest() {
217222
.POST()
218223
.asVoid();
219224

220-
} catch (HttpException e) {
225+
} catch (final HttpException e) {
221226
assertEquals(422, e.statusCode());
222227

223228
final HttpResponse<?> httpResponse = e.httpResponse();
@@ -331,4 +336,18 @@ void get_controlStatusCode_expect201() {
331336
assertEquals("controlStatusCode", hres.body());
332337
}
333338

339+
@Test
340+
void streamBytesTest() {
341+
final HttpResponse<String> res = client.request()
342+
.path("hello/streamBytes")
343+
.GET()
344+
.asString();
345+
346+
final Optional<String> contentTypeHeaderValueOptional = res.headers().firstValue("Content-Type");
347+
348+
assertThat(contentTypeHeaderValueOptional.isPresent()).isEqualTo(true);
349+
// assertThat(contentTypeHeaderValueOptional.get()).isEqualTo("text/html");
350+
assertThat(res.body()).isEqualTo("Avaje");
351+
assertThat(res.statusCode()).isEqualTo(200);
352+
}
334353
}

tests/test-nima-jsonb/src/main/java/org/example/HelloController.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import io.avaje.http.api.Produces;
1616
import io.avaje.http.api.Put;
1717
import io.avaje.http.api.QueryParam;
18+
import io.avaje.http.api.StreamingOutput;
1819
import io.avaje.http.api.Valid;
1920
import io.helidon.common.media.type.MediaTypes;
2021
import io.helidon.webserver.http.ServerRequest;
@@ -164,4 +165,10 @@ String formBean(MyForm form) {
164165
String testBigInt(BigInteger val, BigInteger someQueryParam) {
165166
return "hi|" + val;
166167
}
168+
169+
@Get("streamBytes")
170+
@Produces(value = "text/html", statusCode = 200)
171+
StreamingOutput streamBytes() {
172+
return outputStream -> outputStream.write(new byte[] {0x41, 0x76, 0x61, 0x6a, 0x65});
173+
}
167174
}

0 commit comments

Comments
 (0)