diff --git a/changes.md b/changes.md index 9cc07086b0..e15298cdc2 100644 --- a/changes.md +++ b/changes.md @@ -72,6 +72,8 @@ Changes log continues to be written on a virtual thread. - Active client requests can be aborted; HTTP stream failures surface as I/O errors and a communication-error response callback. + - HTTP/2 and HTTP/3 requests advertise `te: trailers`; stream header names are normalized + to lowercase at the HTTP boundary. - 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/engine/netty/NettyClientCall.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientCall.java index 9b8a509347..09d9afc98c 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 @@ -30,6 +30,7 @@ import java.io.InterruptedIOException; import java.io.PushbackInputStream; import java.net.ConnectException; +import java.util.Locale; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -590,7 +591,11 @@ private void writeRequest(Representation entity) throws IOException { if (!requestTrailers.isEmpty()) { DefaultLastHttpContent withTrailers = new DefaultLastHttpContent(Unpooled.EMPTY_BUFFER); for (Header trailer : requestTrailers) { - withTrailers.trailingHeaders().add(trailer.getName(), trailer.getValue()); + String name = + kind.isStream() + ? trailer.getName().toLowerCase(Locale.ROOT) + : trailer.getName(); + withTrailers.trailingHeaders().add(name, trailer.getValue()); } lastContent = withTrailers; } @@ -620,6 +625,9 @@ private HttpRequest newRequestHead() { String name = header.getName(); if (!HeaderConstants.HEADER_CONTENT_LENGTH.equalsIgnoreCase(name) && !HeaderConstants.HEADER_TRANSFER_ENCODING.equalsIgnoreCase(name)) { + if (kind.isStream()) { + name = name.toLowerCase(Locale.ROOT); + } headers.add(name, header.getValue()); } } @@ -632,6 +640,7 @@ private HttpRequest newRequestHead() { if (kind.isStream()) { headers.remove(HttpHeaderNames.CONNECTION); headers.remove("Keep-Alive"); + headers.set("te", "trailers"); // The HTTP/2 and HTTP/3 codecs read the ":scheme" pseudo-header from their extension // header. headers.set( @@ -715,7 +724,8 @@ private void awaitHead() throws IOException { */ void onResponseHead(HttpResponse response) { for (Map.Entry entry : response.headers()) { - String name = entry.getKey(); + String name = + kind.isStream() ? entry.getKey().toLowerCase(Locale.ROOT) : entry.getKey(); if (!name.regionMatches(true, 0, "x-http2-", 0, 8) && !name.regionMatches(true, 0, "x-http3-", 0, 8)) { responseHeaders.add(name, entry.getValue()); @@ -770,7 +780,11 @@ 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()); + String name = + kind.isStream() + ? trailer.getKey().toLowerCase(Locale.ROOT) + : trailer.getKey(); + currentResponse.getTrailers().add(name, trailer.getValue()); } } ChannelInputStream stream = this.body; 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 f1a95fdd2c..f67640d483 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 @@ -41,6 +41,7 @@ import java.util.Arrays; import java.util.HexFormat; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; @@ -151,24 +152,30 @@ class NettyServerCall { this.request = request; this.requestBody = requestBody; this.confidential = confidential; - this.requestHeaders = copyHeaders(request); + this.requestHeaders = copyHeaders(request, kind.isStream()); } /** * Copies the Netty request headers, except the HTTP/2 pseudo-headers. HTTP/2 and HTTP/3 carry * the host in the ":authority" pseudo-header, mapped to a "Host" header when absent. */ - private static Series
copyHeaders(HttpRequest request) { + private static Series
copyHeaders(HttpRequest request, boolean lowerCaseNames) { Series
result = new Series<>(Header.class); for (Map.Entry header : request.headers()) { if (!header.getKey().startsWith(":")) { - result.add(header.getKey(), header.getValue()); + String name = + lowerCaseNames ? header.getKey().toLowerCase(Locale.ROOT) : header.getKey(); + result.add(name, header.getValue()); } } if (result.getFirstValue(HeaderConstants.HEADER_HOST, true) == null) { String authority = request.headers().get(":authority"); if (authority != null) { - result.add(HeaderConstants.HEADER_HOST, authority); + result.add( + lowerCaseNames + ? HeaderConstants.HEADER_HOST.toLowerCase(Locale.ROOT) + : HeaderConstants.HEADER_HOST, + authority); } } return result; @@ -266,7 +273,9 @@ Series
getRequestTrailers() { /** 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()); + String name = + kind.isStream() ? trailer.getKey().toLowerCase(Locale.ROOT) : trailer.getKey(); + requestTrailers.add(name, trailer.getValue()); } } @@ -539,11 +548,13 @@ private void writeHead(boolean emptyBody) { HttpResponse head = new DefaultHttpResponse(HttpVersion.HTTP_1_1, status); for (Header header : responseHeaders) { - head.headers().add(header.getName(), header.getValue()); + String name = + kind.isStream() ? header.getName().toLowerCase(Locale.ROOT) : header.getName(); + head.headers().add(name, header.getValue()); } if (altSvc != null && !head.headers().contains("Alt-Svc")) { - head.headers().set("Alt-Svc", altSvc); + head.headers().set(kind.isStream() ? "alt-svc" : "Alt-Svc", altSvc); } boolean hasTrailers = !responseTrailers.isEmpty(); @@ -612,7 +623,7 @@ void complete() { LastHttpContent lastContent = LastHttpContent.EMPTY_LAST_CONTENT; if (responseTrailersEnabled) { DefaultLastHttpContent withTrailers = new DefaultLastHttpContent(Unpooled.EMPTY_BUFFER); - copyHeaders(responseTrailers, withTrailers.trailingHeaders()); + copyHeaders(responseTrailers, withTrailers.trailingHeaders(), kind.isStream()); lastContent = withTrailers; } ChannelFuture future = channel.writeAndFlush(lastContent); @@ -639,9 +650,13 @@ static void setTrailerNames( } private static void copyHeaders( - Series
source, io.netty.handler.codec.http.HttpHeaders target) { + Series
source, + io.netty.handler.codec.http.HttpHeaders target, + boolean lowerCaseNames) { for (Header header : source) { - target.add(header.getName(), header.getValue()); + String name = + lowerCaseNames ? header.getName().toLowerCase(Locale.ROOT) : header.getName(); + target.add(name, header.getValue()); } } diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/Http2TrailerHeadersTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/Http2TrailerHeadersTestCase.java new file mode 100644 index 0000000000..f2f009ebbe --- /dev/null +++ b/org.restlet/src/test/java/org/restlet/engine/connector/Http2TrailerHeadersTestCase.java @@ -0,0 +1,107 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Locale; +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; + +class Http2TrailerHeadersTestCase { + + private final AtomicReference receivedTe = new AtomicReference<>(); + + private final AtomicReference> receivedHeaderNames = 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(); + } + + @Test + void sendsTeTrailersAndNormalizesHttp2HeaderNames() throws Exception { + Restlet echo = + new Restlet() { + @Override + public void handle(Request request, Response response) { + receivedTe.set(request.getHeaders().getFirstValue("te", true)); + receivedHeaderNames.set( + request.getHeaders().stream().map(Header::getName).toList()); + response.getHeaders().add(new Header("X-Response-Header", "response")); + response.getTrailers().add(new Header("X-Response-Trailer", "response")); + response.setEntity("ok", MediaType.TEXT_PLAIN); + } + }; + Context serverContext = new Context(); + serverContext.getParameters().add("http.transport.protocol", "HTTP2"); + server = new Server(serverContext, Protocol.HTTP, 0, echo); + server.start(); + + Context clientContext = new Context(); + clientContext.getParameters().add("httpClientTransportMode", "HTTP2"); + client = new Client(clientContext, Protocol.HTTP); + client.start(); + + Request request = + new Request(Method.GET, "http://localhost:" + server.getActualPort() + "/"); + request.getHeaders().add(new Header("X-Request-Header", "request")); + + Response response = client.handle(request); + + assertEquals("trailers", receivedTe.get()); + assertTrue( + receivedHeaderNames.get().stream() + .allMatch(name -> name.equals(name.toLowerCase(Locale.ROOT)))); + assertEquals("ok", response.getEntityAsText()); + assertTrue( + response.getHeaders().stream() + .allMatch( + header -> + header.getName() + .equals( + header.getName() + .toLowerCase(Locale.ROOT)))); + assertEquals("response", response.getTrailers().getFirstValue("x-response-trailer", true)); + assertEquals("x-response-trailer", response.getTrailers().get(0).getName()); + } +}