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; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.io.*; 022import java.net.*; 023import java.nio.charset.*; 024import java.time.*; 025import java.util.*; 026import java.util.function.*; 027import java.util.logging.*; 028 029import org.apache.juneau.http.*; 030import org.apache.juneau.http.entity.*; 031import org.apache.juneau.http.header.*; 032import org.apache.juneau.http.part.*; 033import org.apache.juneau.marshall.sse.*; 034 035/** 036 * Fluent builder for a single HTTP request. 037 * 038 * <p> 039 * Obtain instances from the {@link RestClient} factory methods (e.g. {@link RestClient#get(String)}). 040 * 041 * @since 9.2.1 042 */ 043@SuppressWarnings({ 044 "java:S1192", // Duplicate string literals are HTTP header names and REST protocol wire values; intentional 045 "java:S3776", // Cognitive complexity acceptable for request-building and dispatch logic. 046 "resource" // client is not owned here; run() transfers RestResponse ownership to caller 047}) 048public final class RestRequest { 049 050 private final RestClient client; 051 private final String method; 052 private final String url; 053 private final List<HttpHeader> headers = l(); 054 private final List<HttpPart> queryData = l(); 055 private final List<HttpPart> formData = l(); 056 private final Map<String,Object> pathData = m(); 057 private HttpBody body; 058 private TransportBody convertedBody; 059 private URI resolvedUri; 060 private Duration timeout; 061 // Per-request interceptors, unioned with the client-level interceptors at run() time. 062 private final List<RestCallInterceptor> requestInterceptors = l(); 063 private List<TransportHeader> resolvedHeaders = List.of(); 064 private byte[] cachedContent; 065 private long cachedContentLength = -1; 066 private Throwable exception; 067 private Duration execTime; 068 private boolean policyEnforced; 069 private boolean allowPrivateUrls; 070 // The X-Request-Id correlation id sent on this request, captured at onInit (minted or caller-supplied). Read at 071 // debug-emit time as a stored field — never via a live LogContext scope that may not survive to emission. 072 private String requestId; 073 074 RestRequest(RestClient client, String method, String url) { 075 this.client = client; 076 this.method = method; 077 this.url = url; 078 // Inherit client defaults 079 this.headers.addAll(client.defaultHeaders); 080 this.queryData.addAll(client.defaultQueryData); 081 } 082 083 // -------------------------------------------------- 084 // Header 085 // -------------------------------------------------- 086 087 /** 088 * Adds request headers. 089 * 090 * @param value The headers to add. Must not be <jk>null</jk>. 091 * @return This object. 092 */ 093 public RestRequest headers(HttpHeader... value) { 094 headers.addAll(Arrays.asList(value)); 095 return this; 096 } 097 098 /** 099 * Adds a request header. 100 * 101 * @param name The header name. Must not be <jk>null</jk>. 102 * @param value The header value. May be <jk>null</jk> to omit. 103 * @return This object. 104 */ 105 public RestRequest header(String name, String value) { 106 headers.add(HttpHeaderBean.of(name, value)); 107 return this; 108 } 109 110 /** 111 * Adds a request header with a lazy value. 112 * 113 * @param name The header name. Must not be <jk>null</jk>. 114 * @param value Supplier for the value. Must not be <jk>null</jk>. 115 * @return This object. 116 */ 117 public RestRequest header(String name, Supplier<String> value) { 118 headers.add(HttpHeaderBean.of(name, value)); 119 return this; 120 } 121 122 /** 123 * Adds a <c>No-Trace: true</c> header, asking the server to keep the stack trace of a failed call out of its log. 124 * 125 * <p> 126 * The server still logs the one-line access record; only the attached exception is omitted. 127 * 128 * @return This object. 129 */ 130 public RestRequest noTrace() { 131 return header(NoTrace.NAME, "true"); 132 } 133 134 /** 135 * Returns <jk>true</jk> if a header with the given name (case-insensitive) is already set on this request. 136 * 137 * <p> 138 * Used by the next-gen remote-proxy engine to honor the precedence of a caller-supplied 139 * {@code @Header} parameter over an annotation-declared {@code accept}/{@code contentType} attribute. 140 * 141 * @param name The header name. Must not be <jk>null</jk>. 142 * @return <jk>true</jk> if the header is present. 143 */ 144 public boolean hasHeader(String name) { 145 return headers.stream().anyMatch(h -> eqic(name, h.getName())); 146 } 147 148 // -------------------------------------------------- 149 // Query 150 // -------------------------------------------------- 151 152 /** 153 * Adds query parameters. 154 * 155 * @param value The parts to add. Must not be <jk>null</jk>. 156 * @return This object. 157 */ 158 public RestRequest queryData(HttpPart... value) { 159 queryData.addAll(Arrays.asList(value)); 160 return this; 161 } 162 163 /** 164 * Adds a query parameter. 165 * 166 * @param name The parameter name. Must not be <jk>null</jk>. 167 * @param value The parameter value. May be <jk>null</jk> to omit. 168 * @return This object. 169 */ 170 public RestRequest queryData(String name, String value) { 171 queryData.add(HttpPartBean.of(name, value)); 172 return this; 173 } 174 175 /** 176 * Adds a query parameter with a lazy value. 177 * 178 * @param name The parameter name. Must not be <jk>null</jk>. 179 * @param value Supplier for the value. Must not be <jk>null</jk>. 180 * @return This object. 181 */ 182 public RestRequest queryData(String name, Supplier<String> value) { 183 queryData.add(HttpPartBean.of(name, value)); 184 return this; 185 } 186 187 // -------------------------------------------------- 188 // Form data 189 // -------------------------------------------------- 190 191 /** 192 * Adds form fields (for {@code application/x-www-form-urlencoded} bodies). 193 * 194 * @param value The parts to add. Must not be <jk>null</jk>. 195 * @return This object. 196 */ 197 public RestRequest formData(HttpPart... value) { 198 formData.addAll(Arrays.asList(value)); 199 return this; 200 } 201 202 /** 203 * Adds a form field. 204 * 205 * @param name The field name. Must not be <jk>null</jk>. 206 * @param value The field value. May be <jk>null</jk> to omit. 207 * @return This object. 208 */ 209 public RestRequest formData(String name, String value) { 210 formData.add(HttpPartBean.of(name, value)); 211 return this; 212 } 213 214 // -------------------------------------------------- 215 // Path substitution 216 // -------------------------------------------------- 217 218 /** 219 * Adds path variable substitutions for URI templates (e.g. replaces {@code {id}} in the URL). 220 * 221 * @param value The path parts to apply. Must not be <jk>null</jk>. 222 * @return This object. 223 */ 224 public RestRequest pathData(HttpPart... value) { 225 for (var p : value) 226 pathData.put(p.getName(), p.getValue()); 227 return this; 228 } 229 230 /** 231 * Adds a path variable. 232 * 233 * @param name The variable name (without braces). Must not be <jk>null</jk>. 234 * @param value The replacement value. May be <jk>null</jk>. 235 * @return This object. 236 */ 237 public RestRequest pathData(String name, Object value) { 238 pathData.put(name, value); 239 return this; 240 } 241 242 // -------------------------------------------------- 243 // Body 244 // -------------------------------------------------- 245 246 /** 247 * Sets the request body directly from an {@link HttpBody}. 248 * 249 * @param value The body. May be <jk>null</jk>. 250 * @return This object. 251 */ 252 public RestRequest body(HttpBody value) { 253 body = value; 254 convertedBody = null; 255 return this; 256 } 257 258 /** 259 * Sets the request body from an arbitrary Java object. 260 * 261 * <p> 262 * The object is first run through the client's body converter chain. The default converters handle: 263 * {@link HttpBody} (passthrough), {@link InputStream}, {@code byte[]}, and {@link File}. 264 * Custom converters can be registered on the builder. 265 * 266 * <p> 267 * If no converter matches, the object is serialized with the client's explicitly-configured default serializer 268 * ({@link RestClient#getDefaultSerializer()}) and sent as a string body using the serializer's content type. When 269 * no default serializer is configured, an {@link IllegalStateException} is raised (there is no implicit JSON 270 * fallback) — configure one via {@link RestClient.Builder#defaultSerializer(org.apache.juneau.marshall.serializer.Serializer)}. 271 * 272 * @param value The body object. May be <jk>null</jk> to clear the body. 273 * @return This object. 274 * @throws IOException If a converter fails or the default serializer fails. 275 * @throws IllegalStateException If no converter matches and no default serializer is configured on the client. 276 */ 277 public RestRequest body(Object value) throws IOException { 278 if (value == null) { 279 body = null; 280 convertedBody = null; 281 return this; 282 } 283 for (var converter : client.bodyConverters) { 284 if (converter.canConvert(value)) { 285 convertedBody = converter.convert(value); 286 body = null; 287 return this; 288 } 289 } 290 // No converter matched: stream the POJO through the client's default serializer. 291 // Mirrors classic SerializedEntity — the serializer writes straight to the transport output stream during 292 // run() rather than being pre-materialized into a String/byte[]. Repeatable (re-serializes on resend), so a 293 // future auto-retry can resend it; streaming bodies (InputStream/Reader) remain non-repeatable. 294 var s = client.getDefaultSerializer().orElseThrow(() -> new IllegalStateException( 295 "No default serializer is configured on the client. Configure one via RestClient.Builder.defaultSerializer(...).")); 296 var mt = s.getResponseContentType(); 297 body = SerializerBody.of(s, value, mt != null ? mt.toString() : "application/json"); 298 convertedBody = null; 299 return this; 300 } 301 302 /** 303 * Sets the request body to a token/record-streaming cursor body. 304 * 305 * <p> 306 * Convenience for {@link #body(HttpBody) body(streamBody)} that documents streaming intent and avoids a cast. 307 * The {@link RecordStreamBody} writes directly to the transport output stream during {@link #run()}, so large 308 * payloads are streamed to the wire without being buffered in memory. 309 * 310 * <h5 class='section'>Example:</h5> 311 * <p class='bjava'> 312 * <jv>client</jv>.post(<js>"/bulk-upload"</js>) 313 * .streamBodyEntity(RecordStreamBody.<jsm>records</jsm>(<jv>w</jv> -> { 314 * <jk>for</jk> (Bean <jv>b</jv> : <jv>source</jv>()) 315 * <jv>w</jv>.write(<jv>b</jv>); 316 * })) 317 * .run(); 318 * </p> 319 * 320 * @param value The streaming body. Must not be <jk>null</jk>. 321 * @return This object. 322 */ 323 public RestRequest streamBodyEntity(RecordStreamBody value) { 324 return body(value); 325 } 326 327 /** 328 * Sets the request body to a raw string (UTF-8, {@code text/plain}). 329 * 330 * <p> 331 * Unlike {@link #body(Object)}, this bypasses the converter chain and sends the string as-is. 332 * 333 * @param value The string body. May be <jk>null</jk> to clear the body. 334 * @return This object. 335 */ 336 public RestRequest bodyString(String value) { 337 if (value == null) { 338 body = null; 339 convertedBody = null; 340 } else { 341 body = StringBody.of(value, "text/plain"); 342 convertedBody = null; 343 } 344 return this; 345 } 346 347 // -------------------------------------------------- 348 // Timeout / interceptors 349 // -------------------------------------------------- 350 351 /** 352 * Sets a per-call response timeout for this request. 353 * 354 * <p> 355 * Threaded onto the {@link TransportRequest}. Intended as a response timeout; connect timeouts are otherwise a client-level setting. 356 * 357 * <p> 358 * <b>Transport support:</b> every built-in transport honors this value, using its native per-request mechanism: 359 * the JDK {@code HttpRequest} timeout, an Apache HttpClient 4.5 {@code RequestConfig} (socket + connect timeout), 360 * an Apache HttpClient 5 request-scoped response timeout, an OkHttp per-call read timeout, the Jetty response-header 361 * wait, and a simulated handler deadline in the mock transport. On expiry the transport throws a 362 * {@link TransportException}. When set, it overrides the transport's own default for this request only. 363 * 364 * <p> 365 * A non-<jk>null</jk> value must be positive; zero and negative durations are rejected (matching the JDK 366 * {@code HttpRequest.timeout} contract) so every transport behaves identically. 367 * 368 * @param value The response timeout. May be <jk>null</jk> to use the transport default. 369 * @return This object. 370 * @throws IllegalArgumentException If <c>value</c> is zero or negative. 371 */ 372 public RestRequest timeout(Duration value) { 373 if (value != null && (value.isZero() || value.isNegative())) 374 throw new IllegalArgumentException("Request timeout must be positive: " + value); 375 timeout = value; 376 return this; 377 } 378 379 /** 380 * Marks this request as a {@code @Remote}/{@code @Url} call subject to the SSRF guardrail (see 381 * {@code org.apache.juneau.http.remote.RemoteUrlPolicy}), and records the effective {@code allowPrivateUrls} 382 * opt-in for it. 383 * 384 * <p> 385 * Called by the {@code @Remote}-proxy invocation handler; not intended for direct use by ordinary 386 * {@code RestClient.get(url)}-style calls, which are never policy-covered. 387 * 388 * @param enforced {@code true} if this request is policy-covered. 389 * @param allowPrivateUrls {@code true} if the {@code allowPrivateUrls} opt-in is in effect for this request. 390 * @return This object. 391 */ 392 public RestRequest remoteUrlPolicy(boolean enforced, boolean allowPrivateUrls) { 393 this.policyEnforced = enforced; 394 this.allowPrivateUrls = allowPrivateUrls; 395 return this; 396 } 397 398 /** 399 * Adds per-request lifecycle interceptors. 400 * 401 * <p> 402 * These are unioned with the client-level interceptors when the request runs: the client-level (builder) 403 * interceptors fire first, then these in the order added. The next-gen remote-proxy engine uses this to layer 404 * annotation-declared interface-level then method-level interceptors after the builder-configured ones. 405 * 406 * @param value The interceptors to add. Must not be <jk>null</jk>. 407 * @return This object. 408 */ 409 public RestRequest interceptors(RestCallInterceptor... value) { 410 requestInterceptors.addAll(Arrays.asList(value)); 411 return this; 412 } 413 414 /** 415 * Returns <jk>true</jk> if this request's body can be safely re-sent (retry gating). 416 * 417 * <p> 418 * A request with no body is trivially repeatable. Otherwise the answer is delegated to the underlying 419 * body's {@link HttpBody#isRepeatable()} (or {@link TransportBody#isRepeatable()} for a pre-converted body): 420 * buffered bodies (string/byte[]/file/serialized POJO) are repeatable; one-shot streaming bodies 421 * ({@link InputStream}/{@link Reader}/piped) are not. 422 * 423 * @return <jk>true</jk> if the body is absent or repeatable. 424 */ 425 public boolean isBodyRepeatable() { 426 if (convertedBody != null) 427 return convertedBody.isRepeatable(); 428 if (body != null) 429 return body.isRepeatable(); 430 return true; 431 } 432 433 // -------------------------------------------------- 434 // Accessors (for logging / interceptors) 435 // -------------------------------------------------- 436 437 /** 438 * Returns the HTTP method for this request (e.g. {@code "GET"}). 439 * 440 * @return The method. Never <jk>null</jk>. 441 */ 442 public String getMethod() { 443 return method; 444 } 445 446 /** 447 * Returns the fully resolved URI for this request, or {@code null} if {@link #run()} has not been called yet. 448 * 449 * @return The URI, possibly <jk>null</jk>. 450 */ 451 public URI getUri() { 452 return resolvedUri; 453 } 454 455 /** 456 * Returns the headers resolved onto the transport request. 457 * 458 * @return The resolved headers in wire order. 459 */ 460 public List<TransportHeader> getResolvedHeaders() { 461 return resolvedHeaders; 462 } 463 464 /** 465 * Returns the first resolved request header with the given name (case-insensitive), or {@code null} if absent. 466 * 467 * <p> 468 * Reads from {@link #getResolvedHeaders()} — the finalized, wire-order list — not the pre-conversion header list 469 * {@link #hasHeader(String)} uses internally for its dedup check. 470 * 471 * @param name The header name. Must not be <jk>null</jk>. 472 * @return The first matching header, or <jk>null</jk> if absent. 473 */ 474 public TransportHeader getFirstHeader(String name) { 475 return resolvedHeaders.stream() 476 .filter(h -> eqic(h.name(), name)) 477 .findFirst() 478 .orElse(null); 479 } 480 481 /** 482 * Returns request body bytes captured for debug logging (up to the configured body cap). 483 * 484 * @return Captured request bytes, or <jk>null</jk> if not captured. 485 */ 486 public byte[] getCachedContent() { 487 return cachedContent; 488 } 489 490 /** 491 * Returns the total request body length observed during write, or {@code -1} if unknown. 492 * 493 * @return The total body length. 494 */ 495 public long getCachedContentLength() { 496 return cachedContentLength; 497 } 498 499 /** 500 * Returns the terminal request exception, if any. 501 * 502 * @return The terminal exception, or <jk>null</jk>. 503 */ 504 public Throwable getException() { 505 return exception; 506 } 507 508 /** 509 * Returns the measured request execution time. 510 * 511 * @return The request execution time, or <jk>null</jk> if unset. 512 */ 513 public Duration getExecTime() { 514 return execTime; 515 } 516 517 /** 518 * Sets the {@code X-Request-Id} correlation id for this request (pre-call). 519 * 520 * <p> 521 * Sets both the outgoing {@code X-Request-Id} header and the stored sent-id field returned by 522 * {@link #getRequestId()}. Use this to propagate a caller-owned correlation id; otherwise the built-in auto-send 523 * interceptor mints one at request time (unless disabled via {@link RestClient.Builder#sendRequestId(boolean)}). 524 * 525 * @param value The correlation id. Must not be <jk>null</jk>. 526 * @return This object. 527 * @since 10.0.0 528 */ 529 public RestRequest requestId(String value) { 530 this.requestId = reqnn("value", value); 531 return header(RequestIdConstants.HEADER, value); 532 } 533 534 /** 535 * Returns the {@code X-Request-Id} correlation id sent on this request. 536 * 537 * <p> 538 * Captured at request time (minted by the auto-send interceptor, or the caller-supplied value). Read as a stored 539 * field at debug-emit time — never via a live log-context scope. 540 * 541 * @return The sent correlation id, or <jk>null</jk> if none was sent (e.g. {@code sendRequestId(false)}). 542 * @since 10.0.0 543 */ 544 public String getRequestId() { 545 return requestId; 546 } 547 548 /** 549 * Records the correlation id actually sent on this request (auto-send interceptor internal use). 550 * 551 * @param value The sent id. 552 */ 553 void setSentRequestId(String value) { 554 this.requestId = value; 555 } 556 557 /** 558 * Returns the current value of the {@code X-Request-Id} header set on this request (case-insensitive), or 559 * <jk>null</jk> if none is set (auto-send interceptor internal use). 560 * 561 * @return The header value, or <jk>null</jk>. 562 */ 563 String peekRequestIdHeader() { 564 return headers.stream() 565 .filter(h -> eqic(RequestIdConstants.HEADER, h.getName())) 566 .map(HttpHeader::getValue) 567 .filter(java.util.Objects::nonNull) 568 .findFirst() 569 .orElse(null); 570 } 571 572 // -------------------------------------------------- 573 // Execute 574 // -------------------------------------------------- 575 576 /** 577 * Executes the request and returns the response. 578 * 579 * <p> 580 * The caller is responsible for closing the returned {@link RestResponse}. 581 * 582 * @return The response. Never <jk>null</jk>. 583 * @throws TransportException If a network-level error occurs. 584 * @throws RestCallException If the response could not be processed. 585 */ 586 @SuppressWarnings({ 587 "java:S1130" // RestCallException retained for public-API/source stability: interceptors declare 'throws Exception', so a RestCallException they raise is caught by the precise-rethrow multi-catch below and propagated unchanged; Sonar's flow analysis misses this path. Removing the throws would break callers that catch RestCallException (a sibling checked exception of TransportException). 588 }) 589 public RestResponse run() throws TransportException, RestCallException { 590 var start = Instant.now(); 591 RestResponse response = null; 592 Throwable error = null; 593 var debugLevel = RestClientDebugPipeline.resolveTier(client.debugLogger); 594 var debugCap = client.debugFormatter.bodyCap(); 595 // Union the client-level (builder) interceptors with any per-request (interface- then 596 // method-level) interceptors, preserving the order builder → interface → method. 597 var effectiveInterceptors = effectiveInterceptors(); 598 try { 599 for (var interceptor : effectiveInterceptors) 600 interceptor.onInit(this); 601 602 var transportRequest = buildTransportRequest(); 603 resolvedHeaders = transportRequest.getHeaders(); 604 if (transportRequest.getBody() == null) { 605 cachedContentLength = 0; 606 } else { 607 cachedContentLength = transportRequest.getBody().getContentLength(); 608 } 609 if (debugLevel == Level.FINEST) 610 transportRequest = captureRequestBody(transportRequest, debugCap); 611 if (transportRequest.isSsrfGuardActive() && ! client.transport.supportsUrlPolicy()) 612 throw new TransportException("Refusing to send a policy-covered @Remote request through a transport " 613 + "that cannot honor the SSRF guardrail's connect-time contract (pin-on-connect + redirect " 614 + "revalidation): " + cn(client.transport) + ". Use a first-party transport that supports it, or " 615 + "set allowPrivateUrls(true) if this is an intentional local-dev/intranet target."); 616 var transportResponse = client.transport.execute(transportRequest); 617 response = new RestResponse(transportResponse, client, this, debugLevel, debugCap); 618 619 for (var interceptor : effectiveInterceptors) 620 interceptor.onConnect(this, response); 621 622 execTime = Duration.between(start, Instant.now()); 623 response.setExecTime(execTime); 624 return response; 625 } catch (TransportException | RestCallException e) { 626 error = e; 627 throw e; 628 } catch (Exception e) { 629 error = e; 630 throw new TransportException("Request failed: " + e.getMessage(), e); 631 } finally { 632 var elapsed = Duration.between(start, Instant.now()); 633 execTime = elapsed; 634 exception = error; 635 RestResponse finalResponse = response; 636 if (error != null && finalResponse != null) { 637 // The response was assigned but an error after that point (e.g. a throwing onConnect 638 // interceptor) means it is never returned to the caller, so the caller can never close it 639 // themselves — close it here to avoid leaking the underlying connection/body. 640 try { 641 finalResponse.setExecTime(elapsed); 642 finalResponse.close(); 643 } catch (IOException closeError) { // HTT: close() failing on an already-broken response is not reliably reproducible 644 // suppress — best effort, and the original error is what propagates 645 } 646 } 647 for (var interceptor : effectiveInterceptors) { 648 try { 649 interceptor.onClose(this, finalResponse); 650 } catch (Exception e2) { // HTT: exception in onClose; hard to test reliably 651 // suppress interceptor close errors — best effort 652 } 653 } 654 if (error != null && finalResponse == null) { 655 var syntheticResponse = new RestResponse(TransportResponse.builder().statusCode(0).build(), client, this, debugLevel, debugCap); 656 syntheticResponse.setExecTime(elapsed); 657 RestClientDebugPipeline.emit(client.debugLogger, client.debugFormatter, debugLevel, this, syntheticResponse, error); 658 } 659 } 660 } 661 662 /** 663 * Executes this request and opens a closeable, cancellable cursor over the response as a 664 * <c>text/event-stream</c>. 665 * 666 * <p> 667 * This executes {@link #run()}, asserts a 2xx response status, then opens the response body as an event stream. 668 * Non-2xx responses throw {@link RestCallException} before the body is treated as SSE. 669 * 670 * <p> 671 * The response content type is not validated. This is intentional so callers can branch on response type 672 * (for example <c>application/json</c> vs <c>text/event-stream</c>) when protocols use both. 673 * 674 * <p> 675 * Adds an {@code Accept: text/event-stream} header only when neither this request nor inherited client default 676 * headers already include {@code Accept}. 677 * 678 * <p> 679 * The caller owns the returned {@link SseEventReader}; closing it also closes the underlying 680 * {@link RestResponse} (see {@link ResponseBody#asEventStream()}). If opening the event stream fails, 681 * this method best-effort closes the response before propagating. 682 * 683 * @return A cursor over the live event stream. Never <jk>null</jk>. 684 * @throws IOException If a transport-level error occurs (typically {@link TransportException}), if the response 685 * could not be processed (typically {@link RestCallException}), or if the response has no body to open a 686 * stream over. 687 */ 688 public SseEventReader openEventStream() throws IOException { 689 if (! hasHeader("Accept")) 690 header("Accept", "text/event-stream"); 691 var res = run(); 692 var opened = false; 693 try { 694 var r = res.assertOk().body().asEventStream(); 695 opened = true; 696 return r; 697 } finally { 698 if (! opened) 699 quiet(res::close); 700 } 701 } 702 703 /** Returns the client-level interceptors followed by the per-request ones (builder → interface → method order). */ 704 private List<RestCallInterceptor> effectiveInterceptors() { 705 if (requestInterceptors.isEmpty()) 706 return client.interceptors; 707 var l = new ArrayList<RestCallInterceptor>(client.interceptors.size() + requestInterceptors.size()); 708 l.addAll(client.interceptors); 709 l.addAll(requestInterceptors); 710 return l; 711 } 712 713 @SuppressWarnings({ 714 "java:S3776" // Request-assembly branching is intentional and cohesive; refactoring would reduce readability without benefit. 715 }) 716 private TransportRequest buildTransportRequest() { 717 var resolvedUrl = applyPathSubstitutions(url); 718 resolvedUri = appendQuery(resolvedUrl); 719 720 var builder = TransportRequest.builder() 721 .method(method) 722 .uri(resolvedUri) 723 .timeout(timeout) 724 .remoteUrlPolicy(policyEnforced, allowPrivateUrls) 725 .allowedAddressRanges(client.allowedAddressRanges); 726 727 // Headers 728 for (var h : headers) { 729 var v = h.getValue(); 730 if (v != null) 731 builder.header(h.getName(), v); 732 } 733 734 if (client.parsers != null || client.defaultParser != null) { 735 var hasAccept = headers.stream().anyMatch(h -> eqic("Accept", h.getName())); 736 if (! hasAccept) { 737 var accept = client.getDefaultAccept(); 738 if (accept != null) 739 builder.header("Accept", accept); 740 } 741 } 742 743 // A Content-Type already present in the header list (a caller-supplied @Header param, or a constant header) 744 // wins over the body's own content type, and is never duplicated by it (dedup invariant). 745 var hasContentType = hasHeader("Content-Type"); 746 747 // Pre-converted body from body(Object) takes priority 748 if (convertedBody != null) { 749 if (convertedBody.getContentType() != null && ! hasContentType) 750 builder.header("Content-Type", convertedBody.getContentType()); 751 builder.body(convertedBody); 752 } else if (!formData.isEmpty() && body == null) { 753 // Form body when form data is present and no explicit body was set 754 var formBody = buildFormBody(); 755 if (! hasContentType) 756 builder.header("Content-Type", formBody.getContentType()); 757 builder.body(TransportBody.of(formBody)); 758 } else if (body != null) { 759 if (body.getContentType() != null && ! hasContentType) 760 builder.header("Content-Type", body.getContentType()); 761 builder.body(TransportBody.of(body)); 762 } 763 764 return builder.build(); 765 } 766 767 private TransportRequest captureRequestBody(TransportRequest request, int cap) { 768 var originalBody = request.getBody(); 769 if (originalBody == null) { 770 cachedContent = null; 771 cachedContentLength = 0; 772 return request; 773 } 774 775 var wrappedBody = new HttpBody() { 776 @Override 777 public String getContentType() { 778 return originalBody.getContentType(); 779 } 780 781 @Override 782 public long getContentLength() { 783 return originalBody.getContentLength(); 784 } 785 786 @Override 787 public void writeTo(OutputStream out) throws IOException { 788 var tee = new BoundedCaptureOutputStream(out, cap); 789 try { 790 originalBody.writeTo(tee); 791 } finally { 792 cachedContent = tee.getCapturedBytes(); 793 cachedContentLength = tee.getTotalBytesWritten(); 794 } 795 } 796 797 @Override 798 public boolean isRepeatable() { 799 return originalBody.isRepeatable(); 800 } 801 }; 802 803 var builder = TransportRequest.builder() 804 .method(request.getMethod()) 805 .uri(request.getUri()) 806 .headers(request.getHeaders()) 807 .body(TransportBody.of(wrappedBody)) 808 .timeout(request.getTimeout()) 809 .remoteUrlPolicy(request.isPolicyEnforced(), request.isAllowPrivateUrls()) 810 .allowedAddressRanges(request.getAllowedAddressRanges()); 811 return builder.build(); 812 } 813 814 private String applyPathSubstitutions(String template) { 815 var result = template; 816 Object remainder = null; 817 for (var entry : pathData.entrySet()) { 818 if (eq(entry.getKey(), "/*")) { 819 remainder = entry.getValue(); // @PathRemainder — applied after named substitutions 820 continue; 821 } 822 var replacement = entry.getValue() != null ? entry.getValue().toString() : ""; 823 result = result.replace("{" + entry.getKey() + "}", urlEncode(replacement)); 824 } 825 if (remainder != null) { 826 var r = remainder.toString(); 827 if (! r.isEmpty()) { 828 if (result.endsWith("/*")) 829 result = result.substring(0, result.length() - 2); 830 if (! result.endsWith("/")) 831 result += "/"; 832 result += urlEncodePath(r); 833 } 834 } 835 return result; 836 } 837 838 /** URL-encodes a path remainder while preserving {@code /} segment separators. */ 839 private static String urlEncodePath(String value) { 840 var segments = value.split("/", -1); 841 var sb = new StringBuilder(); 842 for (var i = 0; i < segments.length; i++) { 843 if (i > 0) 844 sb.append('/'); 845 sb.append(urlEncode(segments[i])); 846 } 847 return sb.toString(); 848 } 849 850 private URI appendQuery(String baseUrl) { 851 if (queryData.isEmpty()) { 852 try { 853 return new URI(baseUrl); 854 } catch (URISyntaxException e) { 855 throw iaex(e, "Invalid URL: %s", baseUrl); 856 } 857 } 858 var sb = new StringBuilder(baseUrl); 859 var first = !baseUrl.contains("?"); 860 for (var part : queryData) { 861 var v = part.getValue(); 862 if (v == null) 863 continue; 864 sb.append(first ? '?' : '&'); 865 sb.append(urlEncode(part.getName())).append('=').append(urlEncode(v)); 866 first = false; 867 } 868 try { 869 return new URI(sb.toString()); 870 } catch (URISyntaxException e) { 871 throw iaex(e, "Invalid URL: %s", sb); 872 } 873 } 874 875 private HttpBody buildFormBody() { 876 var sb = new StringBuilder(); 877 var first = true; 878 for (var part : formData) { 879 var v = part.getValue(); 880 if (v == null) 881 continue; 882 if (!first) 883 sb.append('&'); 884 sb.append(urlEncode(part.getName())).append('=').append(urlEncode(v)); 885 first = false; 886 } 887 final var encoded = sb.toString(); 888 return new HttpBody() { 889 @Override public String getContentType() { return "application/x-www-form-urlencoded"; } 890 @Override public long getContentLength() { return encoded.getBytes(StandardCharsets.UTF_8).length; } 891 @Override public void writeTo(OutputStream out) throws IOException { out.write(encoded.getBytes(StandardCharsets.UTF_8)); } 892 @Override public boolean isRepeatable() { return true; } 893 }; 894 } 895 896 private static String urlEncode(String value) { 897 return URLEncoder.encode(value, StandardCharsets.UTF_8); 898 } 899 900 private static final class BoundedCaptureOutputStream extends OutputStream { 901 private final OutputStream delegate; 902 private final ByteArrayOutputStream capture = new ByteArrayOutputStream(); 903 private final int cap; 904 private long totalBytesWritten; 905 906 BoundedCaptureOutputStream(OutputStream delegate, int cap) { 907 this.delegate = delegate; 908 this.cap = cap; 909 } 910 911 byte[] getCapturedBytes() { 912 return capture.toByteArray(); 913 } 914 915 long getTotalBytesWritten() { 916 return totalBytesWritten; 917 } 918 919 @Override 920 public void write(int b) throws IOException { 921 delegate.write(b); 922 totalBytesWritten++; 923 if (capture.size() < cap) 924 capture.write(b); 925 } 926 927 @Override 928 public void write(byte[] b, int off, int len) throws IOException { 929 delegate.write(b, off, len); 930 totalBytesWritten += len; 931 var remaining = cap - capture.size(); 932 if (remaining > 0) 933 capture.write(b, off, Math.min(len, remaining)); 934 } 935 } 936}