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