diff --git a/changes.md b/changes.md index b17e9584ee..96fafca475 100644 --- a/changes.md +++ b/changes.md @@ -72,6 +72,8 @@ Changes log `Response.toString()` now shows it. - Added request and response trailers to the HTTP connectors, supporting HTTP/1.1 chunked transfers and HTTP/2 trailing headers. + - HTTP/2 and HTTP/3 responses can add trailers after streaming the response head and body; + HTTP/1.1 still requires trailer names to be known before the response head is sent. - HTTP/2 clients receive a response as soon as its headers arrive, while the request entity continues to be written on a virtual thread. - Active client requests can be aborted; HTTP stream failures surface as I/O errors and a diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java index f4b0623a5e..ddbb4e7039 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java @@ -583,7 +583,8 @@ private void writeHead(boolean emptyBody) { if (noBodyStatus || "HEAD".equals(getMethod())) { hasTrailers = false; } - responseTrailersEnabled = hasTrailers; + responseTrailersEnabled = + hasTrailers || (kind.isStream() && !noBodyStatus && !"HEAD".equals(getMethod())); if (kind.isStream()) { // Connection-specific headers are forbidden in HTTP/2 and HTTP/3. diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/Http3TestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/Http3TestCase.java index 8adea6b4e2..f0e5365315 100644 --- a/org.restlet/src/test/java/org/restlet/engine/connector/Http3TestCase.java +++ b/org.restlet/src/test/java/org/restlet/engine/connector/Http3TestCase.java @@ -18,6 +18,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.cert.X509Certificate; @@ -175,6 +176,32 @@ void getAndPutOverHttp3() throws Exception { assertEquals(Set.of("HTTP/3.0"), versions); } + @Test + void exchangesTrailersAddedAfterTheFirstBodyFlushOverHttp3() throws Exception { + Restlet streaming = + new Restlet() { + @Override + public void handle(Request request, Response response) { + response.setEntity( + new OutputRepresentation(MediaType.APPLICATION_OCTET_STREAM) { + @Override + public void write(OutputStream output) throws IOException { + output.write("first".getBytes(StandardCharsets.UTF_8)); + output.flush(); + response.getTrailers().add("X-Response-Trailer", "late"); + output.write("second".getBytes(StandardCharsets.UTF_8)); + } + }); + } + }; + startServer("HTTP1_1,HTTP3", streaming); + startClient("HTTP3"); + + Response response = client.handle(new Request(Method.GET, uri("/"))); + assertEquals("firstsecond", response.getEntityAsText()); + assertEquals("late", response.getTrailers().getFirstValue("X-Response-Trailer", true)); + } + @Test void tcpResponsesAdvertiseHttp3() throws Exception { startServer("HTTP2,HTTP1_1,HTTP3", echo); diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/HttpTrailersTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/HttpTrailersTestCase.java index e346deffe1..d3dbd8eba1 100644 --- a/org.restlet/src/test/java/org/restlet/engine/connector/HttpTrailersTestCase.java +++ b/org.restlet/src/test/java/org/restlet/engine/connector/HttpTrailersTestCase.java @@ -10,6 +10,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -25,6 +28,7 @@ import org.restlet.data.Method; import org.restlet.data.Protocol; import org.restlet.engine.Engine; +import org.restlet.representation.OutputRepresentation; import org.restlet.representation.StringRepresentation; class HttpTrailersTestCase { @@ -107,4 +111,38 @@ void exchangesTrailersOverHttp11() throws Exception { void exchangesTrailersOverHttp2() throws Exception { roundTrip("HTTP2", "HTTP2"); } + + @Test + void exchangesHttp2TrailersAddedAfterTheFirstBodyFlush() throws Exception { + Restlet streaming = + new Restlet() { + @Override + public void handle(Request request, Response response) { + response.setEntity( + new OutputRepresentation(MediaType.APPLICATION_OCTET_STREAM) { + @Override + public void write(OutputStream output) throws IOException { + output.write("first".getBytes(StandardCharsets.UTF_8)); + output.flush(); + response.getTrailers().add(RESPONSE_TRAILER, "late"); + output.write("second".getBytes(StandardCharsets.UTF_8)); + } + }); + } + }; + Context serverContext = new Context(); + serverContext.getParameters().add("http.transport.protocol", "HTTP2"); + server = new Server(serverContext, Protocol.HTTP, 0, streaming); + server.start(); + Context clientContext = new Context(); + clientContext.getParameters().add("httpClientTransportMode", "HTTP2"); + client = new Client(clientContext, Protocol.HTTP); + client.start(); + + Response response = + client.handle( + new Request(Method.GET, "http://localhost:" + server.getActualPort())); + assertEquals("firstsecond", response.getEntityAsText()); + assertEquals("late", response.getTrailers().getFirstValue(RESPONSE_TRAILER, true)); + } }