001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.rest.client.jetty; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.io.*; 022import java.nio.channels.*; 023import java.util.concurrent.*; 024 025import org.apache.juneau.rest.client.*; 026import org.eclipse.jetty.client.*; 027import org.eclipse.jetty.util.*; 028 029/** 030 * {@link HttpTransport} implementation backed by Jetty 12 {@link HttpClient}. 031 * 032 * <p> 033 * This transport is auto-discovered via {@link java.util.ServiceLoader} when 034 * {@code org.eclipse.jetty:jetty-client} is on the classpath. You can also instantiate it explicitly: 035 * 036 * <p class='bjava'> 037 * <jv>transport</jv> = JettyHttpTransport.<jsm>builder</jsm>() 038 * .httpClient(<jk>new</jk> HttpClient()) 039 * .build(); 040 * 041 * <jv>client</jv> = RestClient.<jsm>builder</jsm>() 042 * .transport(<jv>transport</jv>) 043 * .build(); 044 * </p> 045 * 046 * <p> 047 * The Jetty {@link HttpClient} is started automatically when the transport is created and stopped when 048 * {@link #close()} is called. The request body is streamed via {@link OutputStreamRequestContent} using 049 * a background thread, avoiding full in-memory buffering. The response header timeout defaults to 30 seconds 050 * and is configurable via {@link JettyHttpTransportBuilder#responseTimeoutMs(long)}. 051 * 052 * <h5 class='section'>See Also:</h5><ul> 053 * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/NextGenRestClient">Next-Generation REST Client</a> 054 * </ul> 055 * 056 * @since 9.2.1 057 */ 058@SuppressWarnings({ 059 "resource" // Transport methods return Closeables owned by the caller; Eclipse JDT @Owning warning is by design. 060}) 061public final class JettyHttpTransport implements HttpTransport { 062 063 private final HttpClient httpClient; 064 private final long responseTimeoutMs; 065 private final boolean policySupported; 066 067 JettyHttpTransport(JettyHttpTransportBuilder builder) throws Exception { 068 this.policySupported = builder.httpClient == null; 069 this.httpClient = builder.httpClient != null ? builder.httpClient : new CredentialGuardingHttpClient(); 070 this.responseTimeoutMs = builder.responseTimeoutMs; 071 if (this.policySupported) 072 // Only a client this transport created (never yet started) reaches here. Installs a SocketAddressResolver 073 // that pin-on-connects the connections of SSRF-guard-active @Remote requests, which are tagged into a 074 // connection pool of their own (see PolicyPinningSocketAddressResolver); ordinary (non-@Remote) 075 // connections on this same client are completely unaffected. It must be installed 076 // before the client starts, because Jetty rejects a resolver change once started. The default async 077 // resolver is built from the client's executor and scheduler, which are not safely readable until after 078 // start, so the fallback here is Jetty's synchronous SocketAddressResolver.Sync, a supported, 079 // dependency-free implementation, rather than a hand-built copy. 080 this.httpClient.setSocketAddressResolver(new PolicyPinningSocketAddressResolver(new SocketAddressResolver.Sync())); 081 if (!this.httpClient.isStarted()) 082 this.httpClient.start(); 083 } 084 085 /** 086 * Returns a new builder for this transport. 087 * 088 * @return A new builder. Never <jk>null</jk>. 089 */ 090 public static JettyHttpTransportBuilder builder() { 091 return new JettyHttpTransportBuilder(); 092 } 093 094 /** 095 * Returns a new instance backed by a default {@link HttpClient}. 096 * 097 * @return A new instance. Never <jk>null</jk>. 098 * @throws Exception If the Jetty {@link HttpClient} cannot be started. 099 */ 100 public static JettyHttpTransport create() throws Exception { 101 return builder().build(); 102 } 103 104 @Override /* HttpTransport */ 105 public TransportResponse execute(TransportRequest request) throws TransportException { 106 if (request.isSsrfGuardActive()) 107 return executeWithPolicy(request); // HTT: returns normally only after a pinned connect to a genuinely public address (guarded requests have their own connection pool, so they never reuse an unpinned connection), which a hermetic loopback test cannot make 108 try { 109 return sendOnce(request); 110 } catch (StaleConnectionException e) { 111 // A pooled keep-alive connection was torn down by the server before any response was received. 112 // Replay once on a fresh connection, but only when it is provably safe (idempotent + repeatable body). 113 if (! request.isSafeToReplay()) 114 throw e.asTransportException(); 115 try { 116 return sendOnce(request); 117 } catch (StaleConnectionException e2) { 118 throw e2.asTransportException(); 119 } 120 } 121 } 122 123 @Override /* HttpTransport */ 124 public boolean supportsUrlPolicy() { 125 return policySupported; 126 } 127 128 // Juneau-controlled redirect revalidation for a guarded request; every hop is pinned (see buildJettyRequest). 129 private TransportResponse executeWithPolicy(TransportRequest request) throws TransportException { 130 return PolicyEnforcedRedirects.execute(request, this::sendOncePinned); // HTT: same as above; returns normally only after a pinned connect to a public address 131 } 132 133 // Performs one pin-on-connect hop: the hop request is still SSRF-guard-active, so it is tagged into the pinned 134 // connection pool. Jetty's own redirect-following is disabled so PolicyEnforcedRedirects owns the loop. 135 private TransportResponse sendOncePinned(TransportRequest request) throws TransportException { 136 try { 137 return sendOnce(request, false); // HTT: same as above; completes only after a pinned connect to a public address 138 } catch (StaleConnectionException e) { // HTT: needs a pooled connection in the pinned pool that the server then drops, and every connection in that pool was pinned to a public address, which a hermetic loopback test cannot reach 139 throw e.asTransportException(); // HTT: see the catch above 140 } 141 } 142 143 // Performs a single HTTP exchange. Throws StaleConnectionException (a retryable signal) when the failure is a 144 // pre-response stale-connection failure; throws TransportException for every other failure. 145 private TransportResponse sendOnce(TransportRequest request) throws TransportException, StaleConnectionException { 146 return sendOnce(request, true); 147 } 148 149 private TransportResponse sendOnce(TransportRequest request, boolean followRedirects) throws TransportException, StaleConnectionException { 150 var jettyRequest = buildJettyRequest(request).followRedirects(followRedirects); 151 var listener = new InputStreamResponseListener(); 152 jettyRequest.send(listener); 153 Response jettyResponse; 154 try { 155 // A per-request timeout overrides the transport-level response header timeout. 156 var perRequest = request.getTimeout(); 157 long waitMs; 158 if (perRequest != null) 159 waitMs = Math.max(1, perRequest.toMillis()); 160 else if (responseTimeoutMs > 0) 161 waitMs = responseTimeoutMs; 162 else 163 waitMs = Long.MAX_VALUE; 164 jettyResponse = listener.get(waitMs, TimeUnit.MILLISECONDS); 165 } catch (InterruptedException e) { 166 Thread.currentThread().interrupt(); 167 abortQuietly(listener); 168 throw new TransportException("HTTP request interrupted", e); 169 } catch (TimeoutException e) { 170 abortQuietly(listener); 171 throw new TransportException("HTTP request timed out", e); 172 } catch (ExecutionException e) { 173 abortQuietly(listener); 174 var cause = e.getCause(); 175 if (isStaleConnectionFailure(cause)) 176 throw new StaleConnectionException(cause); 177 throw new TransportException("HTTP transport error: " + cause.getMessage(), cause); 178 } 179 return buildTransportResponse(jettyResponse, listener.getInputStream()); 180 } 181 182 // A failure is a stale-connection (pre-response) failure when the server closed the pooled connection before 183 // sending any response. Jetty surfaces this as an end-of-file exception (Jetty's EofException is a subclass) 184 // or a closed-channel exception. Because the response listener only returns once response headers have 185 // arrived, any such failure here is guaranteed to have occurred before any response bytes were read. 186 private static boolean isStaleConnectionFailure(Throwable cause) { 187 return cause instanceof EOFException || cause instanceof ClosedChannelException; 188 } 189 190 // Releases the Jetty response content when the response never reaches the caller (interrupt/timeout/error), 191 // so the connection is not abandoned. Closing the listener's input stream aborts and releases the exchange. 192 private static void abortQuietly(InputStreamResponseListener listener) { 193 try { 194 listener.getInputStream().close(); 195 } catch (IOException e) { // HTT: InputStreamResponseListener's input stream close() never throws IOException in practice; the catch only satisfies the declared signature 196 // Best-effort cleanup on an already-failing path; nothing more can be done. 197 } 198 } 199 200 @Override /* Closeable */ 201 public void close() throws IOException { 202 try { 203 httpClient.stop(); 204 } catch (Exception e) { 205 throw ioex(e, "Failed to stop Jetty HttpClient: %s", e.getMessage()); 206 } 207 } 208 209 // ----------------------------------------------------------------------------------------------------------------- 210 // Internal helpers 211 // ----------------------------------------------------------------------------------------------------------------- 212 213 private Request buildJettyRequest(TransportRequest request) throws TransportException { 214 Request jettyRequest; 215 try { 216 // A client this transport created strips credential headers from every redirect copy itself; a caller-supplied 217 // client is left unchanged, so its requests are created as CredentialGuardingHttpRequest, which strips them from 218 // its own copies. That is what HttpClient.newRequest(URI) builds too, but in a guarded subclass: Jetty copies a 219 // request for a redirect with the request's own copy(URI), and does not carry its listeners or attributes over, 220 // so the guard cannot be attached to whatever a client's newRequest returns. An override of 221 // newRequest(URI) on a caller-supplied client is therefore not called; see JettyHttpTransportBuilder.httpClient. 222 jettyRequest = (policySupported ? httpClient.newRequest(request.getUri()) : new CredentialGuardingHttpRequest(httpClient, request.getUri())) 223 .method(request.getMethod()); 224 } catch (IllegalArgumentException e) { 225 // Jetty refuses a URI that java.net.URI parsed with a null host (for example an underscore host name). 226 throw new TransportException("HTTP transport error: " + e.getMessage(), e); 227 } 228 // The tag is part of Jetty's destination key, so a guarded request gets a connection pool of its own whose 229 // connections are all opened through the pin; it can never reuse a connection an unguarded request opened. 230 if (request.isSsrfGuardActive()) 231 jettyRequest.tag(PolicyPinningSocketAddressResolver.tagFor(request.getAllowedAddressRanges())); 232 for (var h : request.getHeaders()) 233 jettyRequest.headers(fields -> fields.add(h.name(), h.value())); 234 var body = request.getBody(); 235 if (body != null) 236 jettyRequest.body(buildRequestContent(body)); 237 return jettyRequest; 238 } 239 240 private static OutputStreamRequestContent buildRequestContent(TransportBody body) { 241 var ct = body.getContentType(); 242 var contentType = ct != null ? ct : "application/octet-stream"; 243 var content = new OutputStreamRequestContent(contentType); 244 // Write body to the content's OutputStream on a background thread so Jetty can stream it concurrently. 245 var writer = new Thread(() -> { 246 try (var out = content.getOutputStream()) { 247 body.writeTo(out); 248 } catch (IOException e) { 249 // Closing the stream on error signals Jetty to abort the request. 250 } 251 }, "juneau-ng-body-writer"); 252 writer.setDaemon(true); 253 writer.start(); 254 return content; 255 } 256 257 private static TransportResponse buildTransportResponse(Response jettyResponse, InputStream bodyStream) { 258 var builder = TransportResponse.builder() 259 .statusCode(jettyResponse.getStatus()) 260 .reasonPhrase(jettyResponse.getReason()) 261 .body(bodyStream) 262 // Release the Jetty response InputStream when the RestResponse is closed; TransportResponse.close() only runs this callback. 263 .closeCallback(bodyStream); 264 jettyResponse.getHeaders().forEach(field -> builder.header(field.getName(), field.getValue())); 265 return builder.build(); 266 } 267 268 // Internal signal that a pre-response stale-connection failure occurred and may be retried once. Carries the 269 // original cause so the caller can build the user-visible TransportException if the retry is not attempted or 270 // also fails. 271 private static final class StaleConnectionException extends Exception { 272 private static final long serialVersionUID = 1L; 273 274 StaleConnectionException(Throwable cause) { 275 super(cause); 276 } 277 278 TransportException asTransportException() { 279 return new TransportException("HTTP transport error: " + getCause().getMessage(), getCause()); 280 } 281 } 282}