Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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());
}
}
Expand All @@ -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(
Expand Down Expand Up @@ -715,7 +724,8 @@ private void awaitHead() throws IOException {
*/
void onResponseHead(HttpResponse response) {
for (Map.Entry<String, String> 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());
Expand Down Expand Up @@ -770,7 +780,11 @@ void onResponseComplete(io.netty.handler.codec.http.HttpHeaders trailers) {
org.restlet.Response currentResponse = response;
if (currentResponse != null) {
for (Map.Entry<String, String> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -151,24 +152,30 @@
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<Header> copyHeaders(HttpRequest request) {
private static Series<Header> copyHeaders(HttpRequest request, boolean lowerCaseNames) {
Series<Header> result = new Series<>(Header.class);
for (Map.Entry<String, String> 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;
Expand Down Expand Up @@ -266,7 +273,9 @@
/** Copies the request trailing headers before the body stream is ended. */
void setRequestTrailers(HttpHeaders trailers) {
for (Map.Entry<String, String> trailer : trailers) {
requestTrailers.add(trailer.getKey(), trailer.getValue());
String name =
kind.isStream() ? trailer.getKey().toLowerCase(Locale.ROOT) : trailer.getKey();
requestTrailers.add(name, trailer.getValue());
}
}

Expand Down Expand Up @@ -524,7 +533,7 @@
*
* @param emptyBody True if no body follows.
*/
private void writeHead(boolean emptyBody) {

Check failure on line 536 in org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 28 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=restlet_restlet-framework-java&issues=AaFZhOQJ6QDxsYCyum5u&open=AaFZhOQJ6QDxsYCyum5u&pullRequest=1543
if (responseHeadWritten) {
return;
}
Expand All @@ -539,11 +548,13 @@
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();
Expand Down Expand Up @@ -612,7 +623,7 @@
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);
Expand All @@ -639,9 +650,13 @@
}

private static void copyHeaders(
Series<Header> source, io.netty.handler.codec.http.HttpHeaders target) {
Series<Header> 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());
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Copyright 2005-2026 Qlik
*<p>
* 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
*<p>
* 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<String> receivedTe = new AtomicReference<>();

private final AtomicReference<List<String>> 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());
}
}