From 2f4e74256bc1d726ec70c23c46e6658069b65958 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:26:54 -0400 Subject: [PATCH 1/2] feat: support HTTP request and response trailers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- changes.md | 2 + .../src/main/java/org/restlet/Request.java | 25 ++++ .../src/main/java/org/restlet/Response.java | 22 ++++ .../restlet/engine/netty/NettyClientCall.java | 33 +++++- .../engine/netty/NettyClientHandler.java | 2 +- .../restlet/engine/netty/NettyRequest.java | 5 + .../restlet/engine/netty/NettyServerCall.java | 67 ++++++++++- .../engine/netty/NettyServerHandler.java | 8 ++ .../java/org/restlet/util/WrapperRequest.java | 6 + .../org/restlet/util/WrapperResponse.java | 6 + .../connector/HttpTrailersTestCase.java | 110 ++++++++++++++++++ 11 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 org.restlet/src/test/java/org/restlet/engine/connector/HttpTrailersTestCase.java diff --git a/changes.md b/changes.md index dac4140c09..753c374a9c 100644 --- a/changes.md +++ b/changes.md @@ -66,6 +66,8 @@ Changes log used by the server to reply (for example HTTP/1.1, HTTP/2.0 or HTTP/3.0), set by the HTTP client connector. When unknown, the protocol of the request is returned. `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. - Bugs fixed - Slf4j bridge reports wrong line number and caller class in log4j. Issue #233. - When GET is an allowed method of a server resource, HEAD is now also listed (e.g. in the Allow header of a 405 response). Issue #266. diff --git a/org.restlet/src/main/java/org/restlet/Request.java b/org.restlet/src/main/java/org/restlet/Request.java index b1e5a6e2bd..e05da2e07e 100644 --- a/org.restlet/src/main/java/org/restlet/Request.java +++ b/org.restlet/src/main/java/org/restlet/Request.java @@ -20,6 +20,7 @@ import org.restlet.data.Conditions; import org.restlet.data.Cookie; import org.restlet.data.Encoding; +import org.restlet.data.Header; import org.restlet.data.Language; import org.restlet.data.MediaType; import org.restlet.data.Method; @@ -80,6 +81,9 @@ public static Request getCurrent() { /** The cookies provided by the client. */ private volatile Series cookies; + /** The trailing headers received with the request entity. */ + private volatile Series
trailers; + /** The host reference. */ private volatile Reference hostRef; @@ -198,6 +202,8 @@ public Request(Request request) { getCookies().add(o); } + getTrailers().addAll(request.getTrailers()); + this.hostRef = request.getHostRef(); this.maxForwards = request.getMaxForwards(); this.originalRef = @@ -428,6 +434,25 @@ public Series getCookies() { return c; } + /** + * Returns the modifiable series of trailing headers received with the request entity. Trailers + * become available when the entity has been fully read. + * + * @return The trailing headers. + */ + public Series
getTrailers() { + Series
result = this.trailers; + if (result == null) { + synchronized (this) { + result = this.trailers; + if (result == null) { + this.trailers = result = new Series<>(Header.class); + } + } + } + return result; + } + /** * Returns the host reference. This may be different from the resourceRef's host, for example, * for URNs and other URIs that don't contain host information.
diff --git a/org.restlet/src/main/java/org/restlet/Response.java b/org.restlet/src/main/java/org/restlet/Response.java index aff2c76c79..82cc5a96ab 100644 --- a/org.restlet/src/main/java/org/restlet/Response.java +++ b/org.restlet/src/main/java/org/restlet/Response.java @@ -18,6 +18,7 @@ import org.restlet.data.ChallengeRequest; import org.restlet.data.CookieSetting; import org.restlet.data.Dimension; +import org.restlet.data.Header; import org.restlet.data.Method; import org.restlet.data.Protocol; import org.restlet.data.Reference; @@ -121,6 +122,9 @@ public static void setCurrent(Response response) { /** The cookie settings provided by the server. */ private volatile Series cookieSettings; + /** The trailing headers sent with the response entity. */ + private volatile Series
trailers; + /** The set of dimensions on which the response entity may vary. */ private volatile Set dimensions; @@ -409,6 +413,24 @@ public Series getCookieSettings() { return c; } + /** + * Returns the modifiable series of trailing headers to send with the response entity. + * + * @return The trailing headers. + */ + public Series
getTrailers() { + Series
result = this.trailers; + if (result == null) { + synchronized (this) { + result = this.trailers; + if (result == null) { + this.trailers = result = new Series<>(Header.class); + } + } + } + return result; + } + /** * Returns the modifiable set of selecting dimensions on which the response entity may vary. If * some server-side content negotiation is done, this set should be properly updated, other it diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientCall.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientCall.java index 6d5bcac2d5..2a9abc787c 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientCall.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientCall.java @@ -9,10 +9,12 @@ package org.restlet.engine.netty; import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; import io.netty.handler.codec.http.DefaultHttpContent; import io.netty.handler.codec.http.DefaultHttpRequest; +import io.netty.handler.codec.http.DefaultLastHttpContent; import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpHeaderValues; import io.netty.handler.codec.http.HttpHeaders; @@ -93,6 +95,12 @@ class NettyClientCall { /** The response body stream. */ private volatile ChannelInputStream body; + /** The response being updated by this exchange. */ + private volatile org.restlet.Response response; + + /** The request trailers to send after the request entity. */ + private volatile Series
requestTrailers = new Series<>(Header.class); + /** The response status code. */ private volatile int statusCode; @@ -174,6 +182,8 @@ boolean isConfidential() { * @param response The Restlet response to update. */ void handle(org.restlet.Request request, org.restlet.Response response) { + this.response = response; + this.requestTrailers = request.getTrailers(); HeaderUtils.addGeneralHeaders(request, requestHeaders); if (request.getEntity() != null) { HeaderUtils.addEntityHeaders(request.getEntity(), requestHeaders); @@ -363,6 +373,11 @@ private void writeRequest(Representation entity) throws IOException { Channel ch = this.channel; HttpRequest nettyRequest = newRequestHead(); boolean hasBody = setBodyHeaders(nettyRequest, entity); + if (!requestTrailers.isEmpty() && !kind.isStream()) { + HttpUtil.setTransferEncodingChunked(nettyRequest, true); + nettyRequest.headers().remove(HttpHeaderNames.CONTENT_LENGTH); + NettyServerCall.setTrailerNames(nettyRequest.headers(), requestTrailers); + } long timeout = helper.getIdleTimeout(); Writability.await(ch, timeout); @@ -379,7 +394,15 @@ private void writeRequest(Representation entity) throws IOException { } Writability.await(ch, timeout); - check(ch.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)); + LastHttpContent lastContent = LastHttpContent.EMPTY_LAST_CONTENT; + if (!requestTrailers.isEmpty()) { + DefaultLastHttpContent withTrailers = new DefaultLastHttpContent(Unpooled.EMPTY_BUFFER); + for (Header trailer : requestTrailers) { + withTrailers.trailingHeaders().add(trailer.getName(), trailer.getValue()); + } + lastContent = withTrailers; + } + check(ch.writeAndFlush(lastContent)); lock.lock(); try { @@ -550,7 +573,13 @@ boolean onResponseContent(ByteBuf content) { } /** Called on the event loop when the response is fully received. */ - void onResponseComplete() { + void onResponseComplete(io.netty.handler.codec.http.HttpHeaders trailers) { + org.restlet.Response currentResponse = response; + if (currentResponse != null) { + for (Map.Entry trailer : trailers) { + currentResponse.getTrailers().add(trailer.getKey(), trailer.getValue()); + } + } ChannelInputStream stream = this.body; if (stream != null) { stream.end(); diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientHandler.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientHandler.java index 359001d387..7f79b04614 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientHandler.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientHandler.java @@ -111,7 +111,7 @@ private boolean onContent(NettyClientCall current, HttpContent content) { } boolean readMore = current.onResponseContent(content.content()); if (last) { - current.onResponseComplete(); + current.onResponseComplete(((LastHttpContent) content).trailingHeaders()); return false; } return readMore; diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyRequest.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyRequest.java index d8db5ef6a5..941942debc 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyRequest.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyRequest.java @@ -126,6 +126,11 @@ class NettyRequest extends Request { setDate((date != null) ? date : new Date()); } + @Override + public Series
getTrailers() { + return call.getRequestTrailers(); + } + /** Returns the host reference built from the "Host" header. */ private static String hostRef(Protocol protocol, Series
headers) { StringBuilder sb = new StringBuilder(); 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 29fbdb5311..f1a95fdd2c 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 @@ -13,13 +13,16 @@ import static org.restlet.engine.header.HeaderConstants.ATTRIBUTE_HTTPS_SSL_SESSION_ID; import static org.restlet.engine.header.HeaderConstants.ATTRIBUTE_VERSION; +import io.netty.buffer.Unpooled; import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelFutureListener; import io.netty.handler.codec.http.DefaultHttpContent; import io.netty.handler.codec.http.DefaultHttpResponse; +import io.netty.handler.codec.http.DefaultLastHttpContent; import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpHeaderValues; +import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpRequest; import io.netty.handler.codec.http.HttpResponse; import io.netty.handler.codec.http.HttpResponseStatus; @@ -97,6 +100,15 @@ class NettyServerCall { /** The response headers. */ private final Series
responseHeaders = new Series<>(Header.class); + /** The trailing headers received with the request entity. */ + private final Series
requestTrailers = new Series<>(Header.class); + + /** The trailing headers to send with the response entity. */ + private Series
responseTrailers = new Series<>(Header.class); + + /** Indicates whether response trailers are valid for the response being sent. */ + private boolean responseTrailersEnabled; + /** The response status code. */ private volatile int statusCode = 200; @@ -246,6 +258,18 @@ Series
getRequestHeaders() { return requestHeaders; } + /** The trailing headers received with the request entity. */ + Series
getRequestTrailers() { + return requestTrailers; + } + + /** Copies the request trailing headers before the body stream is ended. */ + void setRequestTrailers(HttpHeaders trailers) { + for (Map.Entry trailer : trailers) { + requestTrailers.add(trailer.getKey(), trailer.getValue()); + } + } + /** The exact protocol (HTTP or HTTPS). */ Protocol getProtocol() { return confidential ? Protocol.HTTPS : Protocol.HTTP; @@ -320,6 +344,7 @@ private InputStream peek(InputStream stream) { */ void commit(Response response) { try { + responseTrailers = response.getTrailers(); prepareEntity(response); addResponseHeaders(response); sendResponse(response); @@ -521,9 +546,15 @@ private void writeHead(boolean emptyBody) { head.headers().set("Alt-Svc", altSvc); } + boolean hasTrailers = !responseTrailers.isEmpty(); boolean hasLength = head.headers().contains(HttpHeaderNames.CONTENT_LENGTH); boolean noBodyStatus = code == 204 || code == 304 || code < 200; + if (noBodyStatus || "HEAD".equals(getMethod())) { + hasTrailers = false; + } + responseTrailersEnabled = hasTrailers; + if (kind.isStream()) { // Connection-specific headers are forbidden in HTTP/2 and HTTP/3. head.headers().remove(HttpHeaderNames.CONNECTION); @@ -531,12 +562,21 @@ private void writeHead(boolean emptyBody) { head.headers().remove("Keep-Alive"); } else { if (emptyBody) { - if (!hasLength && !noBodyStatus && !"HEAD".equals(getMethod())) { + if (hasTrailers) { + head.headers().remove(HttpHeaderNames.CONTENT_LENGTH); + HttpUtil.setTransferEncodingChunked(head, true); + } else if (!hasLength && !noBodyStatus && !"HEAD".equals(getMethod())) { head.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0); } + } else if (hasTrailers) { + head.headers().remove(HttpHeaderNames.CONTENT_LENGTH); + HttpUtil.setTransferEncodingChunked(head, true); } else if (!hasLength) { head.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED); } + if (hasTrailers) { + setTrailerNames(head.headers(), responseTrailers); + } if (!HttpUtil.isKeepAlive(request)) { head.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); } @@ -569,7 +609,13 @@ void complete() { } boolean keepAlive = HttpUtil.isKeepAlive(request) && requestBody.isFullyConsumed(); - ChannelFuture future = channel.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT); + LastHttpContent lastContent = LastHttpContent.EMPTY_LAST_CONTENT; + if (responseTrailersEnabled) { + DefaultLastHttpContent withTrailers = new DefaultLastHttpContent(Unpooled.EMPTY_BUFFER); + copyHeaders(responseTrailers, withTrailers.trailingHeaders()); + lastContent = withTrailers; + } + ChannelFuture future = channel.writeAndFlush(lastContent); requestBody.close(); if (!kind.isStream()) { @@ -582,6 +628,23 @@ void complete() { } } + static void setTrailerNames( + io.netty.handler.codec.http.HttpHeaders headers, Series
trailers) { + String names = + trailers.stream() + .map(Header::getName) + .distinct() + .collect(java.util.stream.Collectors.joining(", ")); + headers.set("Trailer", names); + } + + private static void copyHeaders( + Series
source, io.netty.handler.codec.http.HttpHeaders target) { + for (Header header : source) { + target.add(header.getName(), header.getValue()); + } + } + /** * Aborts the exchange by closing the connection. * diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHandler.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHandler.java index 661fa85cca..847e09aedd 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHandler.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHandler.java @@ -48,6 +48,9 @@ class NettyServerHandler extends ChannelInboundHandlerAdapter { /** The body stream of the request in progress, if any. */ private ChannelInputStream currentBody; + /** The call receiving the current request body. */ + private NettyServerCall currentCall; + NettyServerHandler(NettyServerHelper helper, boolean confidential, StreamKind kind) { this.helper = helper; this.confidential = confidential; @@ -108,6 +111,7 @@ private void onRequest(ChannelHandlerContext ctx, HttpRequest request) { NettyServerCall call = new NettyServerCall(helper, ctx.channel(), kind, request, body, confidential); + currentCall = call; if (kind != StreamKind.HTTP3) { call.setAltSvc(helper.getAltSvc()); } @@ -144,6 +148,10 @@ private void onContent(ChannelHandlerContext ctx, HttpContent content) { readMore = body.offer(content.content().retain()); } boolean last = content instanceof LastHttpContent; + if (last && currentCall != null) { + currentCall.setRequestTrailers(((LastHttpContent) content).trailingHeaders()); + currentCall = null; + } content.release(); if (last) { diff --git a/org.restlet/src/main/java/org/restlet/util/WrapperRequest.java b/org.restlet/src/main/java/org/restlet/util/WrapperRequest.java index 22ec304ae3..5eed2c0b95 100644 --- a/org.restlet/src/main/java/org/restlet/util/WrapperRequest.java +++ b/org.restlet/src/main/java/org/restlet/util/WrapperRequest.java @@ -23,6 +23,7 @@ import org.restlet.data.Protocol; import org.restlet.data.Range; import org.restlet.data.Reference; +import org.restlet.data.Header; import org.restlet.representation.Representation; /** @@ -133,6 +134,11 @@ public Series getCookies() { return getWrappedRequest().getCookies(); } + @Override + public Series
getTrailers() { + return getWrappedRequest().getTrailers(); + } + /** * Returns the entity representation. * diff --git a/org.restlet/src/main/java/org/restlet/util/WrapperResponse.java b/org.restlet/src/main/java/org/restlet/util/WrapperResponse.java index d4815add9e..00fd5c1862 100644 --- a/org.restlet/src/main/java/org/restlet/util/WrapperResponse.java +++ b/org.restlet/src/main/java/org/restlet/util/WrapperResponse.java @@ -18,6 +18,7 @@ import org.restlet.data.ChallengeRequest; import org.restlet.data.CookieSetting; import org.restlet.data.Dimension; +import org.restlet.data.Header; import org.restlet.data.MediaType; import org.restlet.data.Method; import org.restlet.data.Protocol; @@ -136,6 +137,11 @@ public Series getCookieSettings() { return getWrappedResponse().getCookieSettings(); } + @Override + public Series
getTrailers() { + return getWrappedResponse().getTrailers(); + } + /** * Returns the set of selecting dimensions on which the response entity may vary. If some * server-side content negotiation is done, this set should be properly updated, other it can be 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 new file mode 100644 index 0000000000..e346deffe1 --- /dev/null +++ b/org.restlet/src/test/java/org/restlet/engine/connector/HttpTrailersTestCase.java @@ -0,0 +1,110 @@ +/** + * Copyright 2005-2026 Qlik + *

+ * The content of this file is subject to the terms of the Apache 2.0 open + * source license available at https://www.opensource.org/licenses/apache-2.0 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.engine.connector; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.restlet.Client; +import org.restlet.Context; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.Server; +import org.restlet.data.Header; +import org.restlet.data.MediaType; +import org.restlet.data.Method; +import org.restlet.data.Protocol; +import org.restlet.engine.Engine; +import org.restlet.representation.StringRepresentation; + +class HttpTrailersTestCase { + + private static final String REQUEST_TRAILER = "X-Request-Trailer"; + + private static final String RESPONSE_TRAILER = "X-Response-Trailer"; + + private final AtomicReference receivedRequestTrailer = new AtomicReference<>(); + + private Server server; + + private Client client; + + @BeforeEach + void setUp() { + Engine.clearThreadLocalVariables(); + Engine.register(false); + Engine.getInstance().getRegisteredServers().add(new HttpServerHelper(null)); + Engine.getInstance().getRegisteredClients().add(new HttpClientHelper(null)); + } + + @AfterEach + void tearDown() throws Exception { + if (client != null) { + client.stop(); + } + if (server != null) { + server.stop(); + } + Engine.clearThreadLocalVariables(); + } + + private void roundTrip(String transportProtocol, String clientMode) throws Exception { + Restlet echo = + new Restlet() { + @Override + public void handle(Request request, Response response) { + String entity = request.getEntityAsText(); + receivedRequestTrailer.set( + request.getTrailers().getFirstValue(REQUEST_TRAILER, true)); + response.getTrailers().add(new Header(RESPONSE_TRAILER, "response")); + response.setEntity(entity, MediaType.TEXT_PLAIN); + } + }; + Context serverContext = new Context(); + if (transportProtocol != null) { + serverContext.getParameters().add("http.transport.protocol", transportProtocol); + } + server = new Server(serverContext, Protocol.HTTP, 0, echo); + server.start(); + + Context clientContext = new Context(); + if (clientMode != null) { + clientContext.getParameters().add("httpClientTransportMode", clientMode); + } + client = new Client(clientContext, Protocol.HTTP); + client.start(); + + Request request = + new Request( + Method.POST, + "http://localhost:" + server.getActualPort() + "/", + new StringRepresentation("request", MediaType.TEXT_PLAIN)); + request.getTrailers().add(new Header(REQUEST_TRAILER, "request")); + + Response response = client.handle(request); + + assertEquals("request", receivedRequestTrailer.get()); + assertEquals("request", response.getEntityAsText()); + assertEquals("response", response.getTrailers().getFirstValue(RESPONSE_TRAILER, true)); + } + + @Test + void exchangesTrailersOverHttp11() throws Exception { + roundTrip("HTTP1_1", "HTTP1_1"); + } + + @Test + void exchangesTrailersOverHttp2() throws Exception { + roundTrip("HTTP2", "HTTP2"); + } +} From 770cc613eec25c1e418b27be2b00f88d546061ff Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:33:58 -0400 Subject: [PATCH 2/2] style: sort request wrapper imports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Jerome Louvel <374450+jlouvel@users.noreply.github.com> --- org.restlet/src/main/java/org/restlet/util/WrapperRequest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org.restlet/src/main/java/org/restlet/util/WrapperRequest.java b/org.restlet/src/main/java/org/restlet/util/WrapperRequest.java index 5eed2c0b95..fc342133f7 100644 --- a/org.restlet/src/main/java/org/restlet/util/WrapperRequest.java +++ b/org.restlet/src/main/java/org/restlet/util/WrapperRequest.java @@ -18,12 +18,12 @@ import org.restlet.data.ClientInfo; import org.restlet.data.Conditions; import org.restlet.data.Cookie; +import org.restlet.data.Header; import org.restlet.data.MediaType; import org.restlet.data.Method; import org.restlet.data.Protocol; import org.restlet.data.Range; import org.restlet.data.Reference; -import org.restlet.data.Header; import org.restlet.representation.Representation; /**