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> -&gt; {
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 &mdash; 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}