diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/EarlyHintsListener.java b/httpclient5/src/main/java/org/apache/hc/client5/http/EarlyHintsListener.java new file mode 100644 index 0000000000..2c45c526c9 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/EarlyHintsListener.java @@ -0,0 +1,79 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http; + +import java.io.IOException; + +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.protocol.HttpContext; + +/** + * Callback interface for receiving {@code 103 Early Hints} (RFC 8297) + * informational responses emitted by the origin server before the final response. + * + *

The listener is invoked zero or more times per request, once for each + * {@code 103} received. It is never invoked for the final + * (non-1xx) response.

+ * + *

Semantics

+ * + * + *

Example

+ *
{@code
+ * HttpClients.custom()
+ *     .setEarlyHintsListener((hints, context) -> {
+ *         for (Header h : hints.getHeaders("Link")) {
+ *             // inspect preload links, metrics, etc.
+ *         }
+ *     })
+ *     .build();
+ * }
+ * + * @since 5.6 + */ +@FunctionalInterface +public interface EarlyHintsListener { + + /** + * Called for each received {@code 103 Early Hints} informational response. + * + * @param hints the {@code 103} response object as received on the wire + * @param context the current execution context (never {@code null}) + * @throws HttpException to signal an HTTP-layer error while handling hints + * @throws IOException to signal an I/O error while handling hints + */ + void onEarlyHints(HttpResponse hints, HttpContext context) throws HttpException, IOException; +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/EarlyHintsAsyncExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/EarlyHintsAsyncExec.java new file mode 100644 index 0000000000..4f3190e631 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/EarlyHintsAsyncExec.java @@ -0,0 +1,108 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.async; + +import org.apache.hc.client5.http.EarlyHintsListener; +import org.apache.hc.client5.http.async.AsyncExecCallback; +import org.apache.hc.client5.http.async.AsyncExecChain; +import org.apache.hc.client5.http.async.AsyncExecChainHandler; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.nio.AsyncEntityProducer; +import org.apache.hc.core5.http.protocol.HttpContext; + +/** + * Execution chain handler that delivers {@code 103 Early Hints} (RFC 8297) + * informational responses to a user-provided {@link org.apache.hc.client5.http.EarlyHintsListener} + * without affecting the final (non-1xx) response processing. + * + *

This handler wraps the downstream {@link org.apache.hc.client5.http.async.AsyncExecCallback} + * and forwards any informational response with code + * {@link org.apache.hc.core5.http.HttpStatus#SC_EARLY_HINTS 103} to the listener. + * All other responses (including the final response) are delegated unchanged.

+ * + * + *

For security and interoperability, applications typically act only on + * headers considered safe in Early Hints (for example, {@code Link} with + * {@code rel=preload} or {@code rel=preconnect}).

+ * + * @see org.apache.hc.client5.http.EarlyHintsListener + * @see org.apache.hc.core5.http.HttpStatus#SC_EARLY_HINTS + * @see org.apache.hc.core5.http.nio.ResponseChannel#sendInformation(HttpResponse, HttpContext) + * @since 5.6 + */ +public final class EarlyHintsAsyncExec implements AsyncExecChainHandler { + private final EarlyHintsListener listener; + + public EarlyHintsAsyncExec(final EarlyHintsListener listener) { + this.listener = listener; + } + + @Override + public void execute(final HttpRequest request, + final AsyncEntityProducer entityProducer, + final AsyncExecChain.Scope scope, + final AsyncExecChain chain, + final AsyncExecCallback callback) throws HttpException, java.io.IOException { + + if (listener == null) { + chain.proceed(request, entityProducer, scope, callback); + return; + } + + chain.proceed(request, entityProducer, scope, new AsyncExecCallback() { + @Override + public void handleInformationResponse(final HttpResponse response) + throws HttpException, java.io.IOException { + if (response.getCode() == HttpStatus.SC_EARLY_HINTS) { + listener.onEarlyHints(response, scope.clientContext); + } + callback.handleInformationResponse(response); + } + + @Override + public org.apache.hc.core5.http.nio.AsyncDataConsumer handleResponse( + final HttpResponse response, final EntityDetails entityDetails) + throws HttpException, java.io.IOException { + return callback.handleResponse(response, entityDetails); + } + + @Override + public void completed() { + callback.completed(); + } + + @Override + public void failed(final Exception cause) { + callback.failed(cause); + } + }); + } +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java index 028604744e..9dd325f109 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/HttpAsyncClientBuilder.java @@ -42,6 +42,7 @@ import org.apache.hc.client5.http.AuthenticationStrategy; import org.apache.hc.client5.http.ConnectionKeepAliveStrategy; +import org.apache.hc.client5.http.EarlyHintsListener; import org.apache.hc.client5.http.HttpRequestRetryStrategy; import org.apache.hc.client5.http.SchemePortResolver; import org.apache.hc.client5.http.UserTokenHandler; @@ -262,6 +263,8 @@ private ExecInterceptorEntry( private ProxySelector proxySelector; + private EarlyHintsListener earlyHintsListener; + /** * Maps {@code Content-Encoding} tokens to decoder factories in insertion order. */ @@ -889,6 +892,22 @@ public HttpAsyncClientBuilder disableContentCompression() { return this; } + /** + * Registers a global {@link org.apache.hc.client5.http.EarlyHintsListener} + * that will be notified when the client receives {@code 103 Early Hints} + * informational responses for any request executed by the built client. + * + * @param listener the listener to receive {@code 103 Early Hints} events, + * or {@code null} to remove the listener + * @return this builder + * @since 5.6 + */ + public final HttpAsyncClientBuilder setEarlyHintsListener(final EarlyHintsListener listener) { + this.earlyHintsListener = listener; + return this; + } + + /** * Request exec chain customization and extension. *

@@ -1026,6 +1045,11 @@ public CloseableHttpAsyncClient build() { authCachingDisabled), ChainElement.CONNECT.name()); + if (earlyHintsListener != null) { + addExecInterceptorBefore(ChainElement.PROTOCOL.name(), "early-hints", + new EarlyHintsAsyncExec(earlyHintsListener)); + } + execChainDefinition.addFirst( new AsyncProtocolExec( targetAuthStrategyCopy, diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/EarlyHintsAsyncExecTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/EarlyHintsAsyncExecTest.java new file mode 100644 index 0000000000..f2e6a81ee5 --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/EarlyHintsAsyncExecTest.java @@ -0,0 +1,202 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.impl.async; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.hc.client5.http.EarlyHintsListener; +import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.EntityDetails; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.URIScheme; +import org.apache.hc.core5.http.impl.BasicEntityDetails; +import org.apache.hc.core5.http.impl.bootstrap.AsyncServerBootstrap; +import org.apache.hc.core5.http.impl.bootstrap.HttpAsyncServer; +import org.apache.hc.core5.http.message.BasicHttpResponse; +import org.apache.hc.core5.http.nio.AsyncServerExchangeHandler; +import org.apache.hc.core5.http.nio.CapacityChannel; +import org.apache.hc.core5.http.nio.DataStreamChannel; +import org.apache.hc.core5.http.nio.ResponseChannel; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.IOReactorConfig; +import org.apache.hc.core5.reactor.ListenerEndpoint; +import org.apache.hc.core5.util.Timeout; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + + +class EarlyHintsAsyncExecTest { + + private HttpAsyncServer server; + + @AfterEach + void tearDown() { + if (server != null) { + server.close(CloseMode.GRACEFUL); + } + } + + @Test + @org.junit.jupiter.api.Timeout(10) + void async_client_receives_103_early_hints_and_final_200() throws Exception { + server = AsyncServerBootstrap.bootstrap() + .setCanonicalHostName("localhost") + .setIOReactorConfig(IOReactorConfig.custom() + .setSoTimeout(Timeout.ofSeconds(5)) + .build()) + .register("/eh", () -> new AsyncServerExchangeHandler() { + + private final byte[] body = "OK".getBytes(StandardCharsets.US_ASCII); + private volatile boolean sentBody; + + @Override + public void handleRequest( + final HttpRequest request, + final EntityDetails entityDetails, + final ResponseChannel channel, + final HttpContext context) throws HttpException { + // Send 103 Early Hints + final BasicHttpResponse hints = new BasicHttpResponse(HttpStatus.SC_EARLY_HINTS); + hints.addHeader("Link", "; rel=preload; as=style"); + hints.addHeader("Link", "; rel=preload; as=script"); + try { + channel.sendInformation(hints, context); + } catch (final Exception ex) { + throw new HttpException(ex.getMessage(), ex); + } + + // Send final 200 response head; body via produce() + final BasicHttpResponse ok = new BasicHttpResponse(HttpStatus.SC_OK); + ok.addHeader("Content-Type", ContentType.TEXT_PLAIN.toString()); + final BasicEntityDetails details = + new BasicEntityDetails(body.length, ContentType.TEXT_PLAIN); + try { + channel.sendResponse(ok, details, context); + } catch (final Exception ex) { + throw new HttpException(ex.getMessage(), ex); + } + } + + @Override + public void updateCapacity(final CapacityChannel capacityChannel) throws IOException { + capacityChannel.update(Integer.MAX_VALUE); + } + + @Override + public void consume(final ByteBuffer src) { /* no-op */ } + + @Override + public void streamEnd(final List trailers) { /* no-op */ } + + // ---- AsyncDataProducer + @Override + public void produce(final DataStreamChannel channel) throws IOException { + if (!sentBody) { + channel.write(ByteBuffer.wrap(body)); + channel.endStream(); + sentBody = true; + } + } + + @Override + public int available() { + return sentBody ? 0 : body.length; + } + + @Override + public void failed(final Exception cause) { /* no-op for test */ } + + @Override + public void releaseResources() { /* no-op for test */ } + }) + .create(); + + server.start(); + + // Bind to ephemeral port and retrieve it from the listener endpoint + final Future lf = server.listen(new InetSocketAddress(0), URIScheme.HTTP); + final ListenerEndpoint ep = lf.get(5, TimeUnit.SECONDS); + final int port = ((InetSocketAddress) ep.getAddress()).getPort(); + + final AtomicInteger hintsCount = new AtomicInteger(); + final AtomicReference> linkHeaders = new AtomicReference<>(new ArrayList<>()); + + final EarlyHintsListener listener = (hints, ctx) -> { + if (hints.getCode() == HttpStatus.SC_EARLY_HINTS) { + hintsCount.incrementAndGet(); + final Header[] hs = hints.getHeaders("Link"); + final ArrayList vals = new ArrayList(hs.length); + for (final Header h : hs) { + vals.add(h.getValue()); + } + linkHeaders.set(vals); + } + }; + + try (final CloseableHttpAsyncClient client = HttpAsyncClients.custom() + .setEarlyHintsListener(listener) + .build()) { + + client.start(); + + final SimpleHttpResponse resp = client.execute( + SimpleRequestBuilder.get("http://localhost:" + port + "/eh").build(), + null).get(5, TimeUnit.SECONDS); + + assertEquals(HttpStatus.SC_OK, resp.getCode(), "Final response must be 200"); + assertEquals("OK", resp.getBodyText()); + } + + assertEquals(1, hintsCount.get(), "Expected exactly one 103 Early Hints callback"); + final List links = linkHeaders.get(); + boolean hasCss = false, hasJs = false; + for (final String v : links) { + if (v.contains("")) hasCss = true; + if (v.contains("")) hasJs = true; + } + assertTrue(hasCss, "Missing style preload link"); + assertTrue(hasJs, "Missing script preload link"); + } +}