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..5cb3c4f228
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/EarlyHintsListener.java
@@ -0,0 +1,59 @@
+/*
+ * ====================================================================
+ * 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
+ *
The listener may be invoked multiple times per request, once for each + * {@code 103} received. It is never invoked for the final (non-1xx) response.
+ * + *Implementations should be fast and non-blocking. If heavy work is needed, + * offload it to an application executor.
+ * + * @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..7aef12cd66 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/EarlyHintsAsyncExec.java @@ -0,0 +1,110 @@ +/* + * ==================================================================== + * 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 + *This handler forwards each {@code 103} informational response 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(org.apache.hc.core5.http.HttpResponse, org.apache.hc.core5.http.protocol.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, 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 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 6fc1d88c1f..26f7022209 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 @@ -43,6 +43,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; @@ -266,6 +267,8 @@ private ExecInterceptorEntry( private ProxySelector proxySelector; + private EarlyHintsListener earlyHintsListener; + /** * Maps {@code Content-Encoding} tokens to decoder factories in insertion order. */ @@ -893,6 +896,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. *
@@ -1035,6 +1054,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/examples/AsyncClientEarlyHintsEndToEnd.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientEarlyHintsEndToEnd.java
new file mode 100644
index 0000000000..67b6a77da7
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientEarlyHintsEndToEnd.java
@@ -0,0 +1,174 @@
+/*
+ * ====================================================================
+ * 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
+ *
This example starts a tiny local async HTTP server that: + *
Use this sample to see how to wire {@code setEarlyHintsListener(...)} and verify that + * Early Hints do not interfere with normal response processing.
+ */ + +public class AsyncClientEarlyHintsEndToEnd { + + public static void main(final String[] args) throws Exception { + // --- Start minimal async server that sends 103 then 200 + final HttpAsyncServer server = AsyncServerBootstrap.bootstrap() + .setCanonicalHostName("localhost") + .register("/eh", () -> new AsyncServerExchangeHandler() { + + private final byte[] body = "OK".getBytes(StandardCharsets.US_ASCII); + private volatile boolean sent; + + @Override + public void handleRequest(final HttpRequest request, + final EntityDetails entityDetails, + final ResponseChannel channel, + final HttpContext context) + throws HttpException, IOException { + + // 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"); + channel.sendInformation(hints, context); + + // Final 200 (announce entity; body will be produced in 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); + channel.sendResponse(ok, details, context); + } + + // ---- AsyncDataConsumer (request body not expected) + @Override + public void updateCapacity(final CapacityChannel ch) throws IOException { + ch.update(Integer.MAX_VALUE); + } + + @Override + public void consume(final ByteBuffer src) { /* no-op */ } + + @Override + public void streamEnd(final List extends Header> trailers) { /* no-op */ } + + // ---- AsyncDataProducer (MUST implement both of these) + @Override + public void produce(final DataStreamChannel ch) throws IOException { + if (!sent) { + ch.write(java.nio.ByteBuffer.wrap(body)); + ch.endStream(); + sent = true; + } + } + + @Override + public int available() { + return sent ? 0 : body.length; + } + + @Override + public void failed(final Exception cause) { /* no-op for demo */ } + + @Override + public void releaseResources() { /* no-op for demo */ } + }) + .create(); + server.start(); + final Future