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