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.remote;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020import static org.apache.juneau.commons.utils.StringUtils.*;
021import static org.apache.juneau.marshall.Constants.*;
022
023import java.io.*;
024import java.lang.reflect.*;
025import java.nio.charset.*;
026import java.time.*;
027import java.time.temporal.Temporal;
028import java.time.temporal.TemporalAmount;
029import java.util.*;
030import java.util.Date;
031import java.util.concurrent.*;
032import java.util.function.*;
033
034import org.apache.juneau.commons.httppart.*;
035import org.apache.juneau.commons.inject.*;
036import org.apache.juneau.http.*;
037import org.apache.juneau.http.entity.*;
038import org.apache.juneau.http.header.*;
039import org.apache.juneau.http.part.*;
040import org.apache.juneau.http.remote.*;
041import org.apache.juneau.http.response.*;
042import org.apache.juneau.marshall.*;
043import org.apache.juneau.marshall.httppart.*;
044import org.apache.juneau.marshall.oapi.*;
045import org.apache.juneau.marshall.parser.*;
046import org.apache.juneau.marshall.serializer.*;
047import org.apache.juneau.marshall.stream.*;
048import org.apache.juneau.rest.client.*;
049
050/**
051 * Creates Java proxy instances for {@link Remote}-annotated interfaces, backed by an {@link RestClient}.
052 *
053 * <p>
054 * Each interface method call translates to an HTTP request via the client.
055 *
056 * <p>
057 * Obtain instances via {@link RestClient#remote(Class)}.
058 *
059 * <p class='bjava'>
060 *    <ja>@Remote</ja>(path=<js>"/api/users"</js>)
061 *    <jk>public interface</jk> UserService {{
062 *       <ja>@RemoteGet</ja>(<js>"/{id}"</js>)
063 *       String getUser(<ja>@Path</ja>(<js>"id"</js>) String <jv>id</jv>);
064 *    }}
065 *
066 *    UserService <jv>svc</jv> = client.remote(UserService.<jk>class</jk>);
067 *    String <jv>user</jv> = <jv>svc</jv>.getUser(<js>"42"</js>);
068 * </p>
069 *
070 * <h5 class='section'>See Also:</h5><ul>
071 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/NextGenRestClient">Next-Generation REST Client</a>
072 * </ul>
073 *
074 * @since 9.2.1
075 */
076@SuppressWarnings({
077   "java:S3776", // buildRequest(), bindParam() and processReturn() dispatch over every @Remote parameter and return kind
078   "resource" // Eclipse resource analysis: client is caller-owned, not closed by this holder
079})
080public final class RemoteClient {
081
082   private final RestClient client;
083
084   /**
085    * Constructor.
086    *
087    * @param client The underlying REST client. Must not be <jk>null</jk>.
088    */
089   public RemoteClient(RestClient client) {
090      this.client = reqnn("client", client);
091   }
092
093   /**
094    * Creates a Java proxy for the given {@link Remote}-annotated interface.
095    *
096    * @param <T> The interface type.
097    * @param iface The interface class. Must be annotated with {@link Remote}. Must not be <jk>null</jk>.
098    * @return A proxy instance. Never <jk>null</jk>.
099    * @throws IllegalArgumentException If {@code iface} is not an interface or not annotated with {@link Remote}.
100    */
101   @SuppressWarnings({
102      "unchecked" // Type erasure on reflective/generic cast; element type is verified at call site
103   })
104   public <T> T create(Class<T> iface) {
105      reqnn("iface", iface);
106      var meta = RrpcInterfaceMeta.of(iface);
107      return (T) Proxy.newProxyInstance(
108         iface.getClassLoader(),
109         new Class<?>[]{iface},
110         new RemoteInvocationHandler(client, meta)
111      );
112   }
113
114   // ------------------------------------------------------------------------------------------------------------------
115   // InvocationHandler
116   // ------------------------------------------------------------------------------------------------------------------
117
118   @SuppressWarnings({
119      "java:S112", // Reflective remote-proxy dispatch intentionally propagates arbitrary exceptions (target-method, parse, transport, and user-declared @Remote exception types) to the caller; narrowing the throws clauses would break that contract.
120      "java:S2143" // Date 'instanceof' check classifies simple-typed @Request args (a type test, not date arithmetic); no behavior-preserving java.time equivalent.
121   })
122   private static final class RemoteInvocationHandler implements InvocationHandler {
123
124      /**
125       * Per-class cache of resolved interceptor instances, mirroring the bean-store-less
126       * {@link RrpcInterfaceMethodMeta#resolvePartSerializer} pattern.  Located here (not on the {@code Rrpc*Meta}
127       * objects) because {@link RestCallInterceptor} lives in this module and the metadata module must not depend on
128       * it.  Resolved instances are independent of any client, so the cache is safely shared statically.
129       */
130      private static final Map<Class<?>,RestCallInterceptor> INTERCEPTORS = new ConcurrentHashMap<>();
131      private static final String HEADER_ACCEPT = "Accept";
132      private static final String MEDIA_OCTET_STREAM = "application/octet-stream";
133
134      /** Builds a fresh {@link RestRequest} for an invocation; re-invoked per retry attempt. */
135      @FunctionalInterface
136      private interface RequestSupplier {
137         RestRequest get() throws IOException;
138      }
139
140      /**
141       * The resolved per-call request body format: the serializer selected for the {@code contentType}
142       * media type and the {@code Content-Type} label to send.  {@link #NONE} means no {@code contentType} attribute
143       * is in effect (use the default body serialization).
144       *
145       * @param serializer The serializer to write the body with (the matched serializer, or the default on no-match).
146       * @param contentType The {@code Content-Type} label to send (always the attribute's media type when set).
147       */
148      private record BodyFormat(Serializer serializer, String contentType) {
149
150         /** The empty body format used when no {@code contentType} attribute is in effect. */
151         static final BodyFormat NONE = new BodyFormat(null, null);
152
153         /** Returns <jk>true</jk> if a {@code contentType} attribute is in effect for this call. */
154         boolean isSet() {
155            return contentType != null;
156         }
157      }
158
159      private final RestClient client;
160      private final RrpcInterfaceMeta meta;
161
162      RemoteInvocationHandler(RestClient client, RrpcInterfaceMeta meta) {
163         this.client = client;
164         this.meta = meta;
165      }
166
167      @Override
168      public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
169         // Handle Object methods directly
170         if (method.getDeclaringClass() == Object.class)
171            return method.invoke(this, args);
172
173         var methodMeta = meta.getMethodMeta(method);
174         if (methodMeta == null)
175            throw uoex("Method '%s' has no @Remote* annotation", method.getName());
176
177         // Build the full path: basePath + methodPath
178         var basePath = meta.getBasePath();
179         var methodPath = methodMeta.getPath();
180         var fullPath = combinePaths(basePath, methodPath);
181
182         // The allowPrivateUrls opt-in is interface-level-or-client-level only (no per-method attribute, mirroring
183         // the design's precedence table collapsed to the surfaces this engine actually exposes).
184         var allowPrivateUrls = meta.isAllowPrivateUrls() || client.isAllowPrivateUrls();
185
186         // Apply the call-time @Url parameter / declarative baseUrl override (computed per call and
187         // never cached on the shared meta objects).  Path-token substitution still runs at request time so @Path params
188         // can fill tokens inside the resolved URL.
189         var effectivePath = resolveEffectiveUrl(methodMeta, method, args, fullPath, allowPrivateUrls);
190
191         // throwOnError is enabled if set true at either the method or interface level.
192         var throwOnError = meta.isThrowOnError() || methodMeta.isThrowOnError();
193
194         // The effective accept media type (method-level overrides interface-level) is the response
195         // parser FALLBACK — consulted only when the response Content-Type matched no registered parser.
196         var acceptFallback = firstNonEmpty(methodMeta.getAccept(), meta.getAccept());
197
198         // Execute and process the return value.  The request is rebuilt per attempt so a (gated) retry resends a
199         // freshly-bound request.
200         return processReturn(() -> buildRequest(methodMeta, effectivePath, method, args, allowPrivateUrls), methodMeta, method, throwOnError, acceptFallback);
201      }
202
203      private RestRequest buildRequest(RrpcInterfaceMethodMeta methodMeta, String path, Method method, Object[] args, boolean allowPrivateUrls) throws IOException {
204         var httpMethod = methodMeta.getHttpMethod();
205         var req = switch (httpMethod) {
206            case "GET" -> client.get(path);
207            case "POST" -> client.post(path);
208            case "PUT" -> client.put(path);
209            case "PATCH" -> client.patch(path);
210            case "DELETE" -> client.delete(path);
211            default -> throw iaex("Unsupported HTTP method: %s", httpMethod); // HTT
212         };
213
214         // Resolve the effective content-negotiation overrides (method-level over interface-level).
215         // contentType SELECTS the request serializer (and sets the Content-Type label); accept sets the Accept
216         // header.  Both win over a constant header of the same name (filtered out below), but a genuinely
217         // caller-supplied @Header param still takes effect (applied after binding).
218         var bodyFormat = resolveBodyFormat(methodMeta);
219         var effectiveAccept = firstNonEmpty(methodMeta.getAccept(), meta.getAccept());
220
221         // Apply always-applied constant parts (interface-level + method-level) to every request.
222         // Within each part type, method-level constants take precedence over interface-level constants of the
223         // same name; a caller-supplied parameter value (bound below) still composes with the constant.  A
224         // constant Content-Type/Accept is dropped when the dedicated contentType/accept attribute is in effect.
225         applyConstantHeaders(req, meta.getHeaders(), methodMeta.getConstantHeaders(), bodyFormat.isSet(), isNotEmpty(effectiveAccept));
226         applyConstants(req::queryData, meta.getQueryData(), methodMeta.getConstantQueryData());
227         applyConstants(req::formData, meta.getFormData(), methodMeta.getConstantFormData());
228
229         if (args != null) {
230            var params = method.getParameters();
231            for (int i = 0; i < params.length; i++)
232               bindParam(req, methodMeta, params[i], args[i], params.length == 1, bodyFormat);
233         }
234
235         // A @Multipart method assembles its body from the @Part parameters (a thin adapter onto the
236         // existing streaming MultipartBody); otherwise apply the param-less constant body.
237         if (methodMeta.isMultipart()) {
238            bindMultipartBody(req, method, args);
239         } else if (! hasBodyParam(method)) {
240            // Param-less constant body: the method-level content default applies when there is no body parameter at all.
241            var contentDefault = methodMeta.getContentDefault();
242            if (contentDefault != null)
243               withContentBody(req, contentDefault, bodyFormat);
244         }
245
246         // Set the Accept header from the dedicated attribute unless a caller-supplied @Header
247         // param already provided one (caller wins per existing semantics); a single Accept header is emitted.
248         if (isNotEmpty(effectiveAccept) && ! req.hasHeader(HEADER_ACCEPT))
249            req.header(HEADER_ACCEPT, effectiveAccept);
250
251         // Apply annotation-declared interceptors + per-call timeout.
252         applyPolicy(req, methodMeta);
253
254         // Mark this request as policy-covered (SSRF guardrail) so the transport applies pin-on-connect +
255         // redirect revalidation, or fails closed if it cannot honor the contract.
256         req.remoteUrlPolicy(true, allowPrivateUrls);
257
258         return req;
259      }
260
261      /**
262       * Resolves the effective request body format from the {@code contentType} attribute
263       * (method-level overriding interface-level).
264       *
265       * <p>
266       * When a {@code contentType} media type is in effect, the matching registered request serializer is selected
267       * from the client's serializer set ({@link RestClient#getSerializerForMediaType(String)}).  If no registered
268       * serializer matches, the client's explicitly-configured default serializer is used to write the body but the
269       * overridden media type is still sent as the {@code Content-Type} label (supporting vendor media types such as
270       * {@code application/vnd.foo+json} whose bytes are really the default format).  If neither a match nor a default
271       * serializer is available, an {@link IllegalStateException} is raised (there is no implicit JSON fallback).
272       *
273       * @param methodMeta The method metadata.
274       * @return The resolved body format; {@link BodyFormat#NONE} when no {@code contentType} attribute is in effect.
275       * @throws IllegalStateException If no serializer matches and no default serializer is configured on the client.
276       */
277      private BodyFormat resolveBodyFormat(RrpcInterfaceMethodMeta methodMeta) {
278         var contentType = firstNonEmpty(methodMeta.getContentType(), meta.getContentType());
279         if (isEmpty(contentType))
280            return BodyFormat.NONE;
281         var serializer = client.getSerializerForMediaType(contentType).or(client::getDefaultSerializer).orElseThrow(() ->
282            isex("No serializer matched Content-Type '%s' and no default serializer is configured on the client.  Configure one via RestClient.Builder.defaultSerializer(...).", contentType));
283         return new BodyFormat(serializer, contentType);
284      }
285
286      /**
287       * Computes the effective request URL for a call, applying the dynamic-URL overrides over the
288       * statically-computed {@code basePath + methodPath}.
289       *
290       * <p>
291       * Precedence (most-specific first):
292       * <ol>
293       *    <li><b>{@code @Url} parameter</b> (call-time, Option A) — its value becomes the effective URL, replacing the
294       *       whole computed path.  A value containing a scheme is absolute (and bypasses the client root URL via the
295       *       existing {@code ://} seam in {@link RestClient#request(String, String)}); a scheme-less value is relative
296       *       and resolved against the client root URL only (Retrofit endpoint replacement — <i>not</i> against the
297       *       interface path).  {@code {var}} tokens are preserved for {@code @Path} substitution at request time.
298       *    <li><b>Method-level {@code baseUrl}</b> (Option B) — substitutes the authority+root, preserving the full path.
299       *    <li><b>Interface-level {@code baseUrl}</b> (Option B) — same, at lower precedence.
300       *    <li><b>Client root URL</b> (default, unchanged behavior — no override applied).
301       * </ol>
302       *
303       * <p>
304       * SSRF guardrail: when an override is in effect and yields an absolute value (carries a URI scheme), or the
305       * default (no-override) {@code fullPath} is itself already absolute (an absolute {@code @RemoteOp(path)} /
306       * templating value), the full deny-private policy applies via {@link RemoteUrlPolicy#requireAllowedUrl}
307       * &mdash; not just the {@code http}/{@code https} scheme check.  Connect-time pin-on-connect and
308       * redirect-revalidation are applied downstream by the transport (see {@link RestRequest#remoteUrlPolicy}).
309       *
310       * @param methodMeta The method metadata (carries the {@code @Url} param index + the method-level {@code baseUrl}).
311       * @param method The invoked method (for error messages).
312       * @param args The invocation arguments (source of the {@code @Url} value).
313       * @param fullPath The statically-computed {@code basePath + methodPath}.
314       * @param allowPrivateUrls The effective {@code allowPrivateUrls} opt-in for this call.
315       * @return The effective URL/path to hand to {@code client.<verb>(...)}.
316       */
317      private String resolveEffectiveUrl(RrpcInterfaceMethodMeta methodMeta, Method method, Object[] args, String fullPath, boolean allowPrivateUrls) {
318         // Option A: a call-time @Url parameter wins over everything.
319         var urlParamIndex = methodMeta.getUrlParamIndex();
320         if (urlParamIndex >= 0) {
321            var arg = (args == null || urlParamIndex >= args.length) ? null : args[urlParamIndex];
322            var value = arg == null ? null : arg.toString();
323            req(! isBlank(value), "@Url parameter on %s.%s must not be null or blank",
324               method.getDeclaringClass().getName(), method.getName());
325            return RemoteUrlPolicy.requireAllowedUrl(value.trim(), allowPrivateUrls, client.getAllowedAddressRanges());
326         }
327         // Option B: a declarative base/host override (method-level wins over interface-level).
328         var baseUrl = firstNonEmpty(methodMeta.getBaseUrl(), meta.getBaseUrl());
329         if (isNotEmpty(baseUrl))
330            return RemoteUrlPolicy.requireAllowedUrl(combinePaths(baseUrl, fullPath), allowPrivateUrls, client.getAllowedAddressRanges());
331         // Default: no override — but an already-absolute fullPath (absolute @RemoteOp(path)/templating) is
332         // still policy-covered (Review R4); a genuinely relative fullPath passes through unchanged.
333         return RemoteUrlPolicy.requireAllowedUrl(fullPath, allowPrivateUrls, client.getAllowedAddressRanges());
334      }
335
336      /**
337       * Applies the cross-cutting call policy to a freshly-built request: annotation-declared
338       * interceptors (interface-level then method-level, unioned <i>after</i> the builder-level interceptors in
339       * {@link RestRequest#run()}) and the per-call response timeout (method-level overrides interface-level).
340       */
341      private void applyPolicy(RestRequest req, RrpcInterfaceMethodMeta methodMeta) {
342         var interceptorClasses = new ArrayList<Class<?>>();
343         interceptorClasses.addAll(Arrays.asList(meta.getInterceptorClasses()));
344         interceptorClasses.addAll(Arrays.asList(methodMeta.getInterceptorClasses()));
345         if (! interceptorClasses.isEmpty()) {
346            var resolved = new RestCallInterceptor[interceptorClasses.size()];
347            for (var i = 0; i < resolved.length; i++)
348               resolved[i] = resolveInterceptor(interceptorClasses.get(i));
349            req.interceptors(resolved);
350         }
351         var timeout = firstNonEmpty(methodMeta.getTimeout(), meta.getTimeout());
352         if (timeout != null && ! timeout.isEmpty()) {
353            var ms = getDuration(timeout);
354            if (ms > 0)
355               req.timeout(Duration.ofMillis(ms));
356         }
357      }
358
359      /**
360       * Resolves an interceptor class to a cached instance via {@link BeanInstantiator} (bean-store-less, mirroring
361       * the per-part-serializer resolver).  The class must implement {@link RestCallInterceptor} and provide a
362       * public no-arg constructor.
363       */
364      private static RestCallInterceptor resolveInterceptor(Class<?> c) {
365         return INTERCEPTORS.computeIfAbsent(c, k -> (RestCallInterceptor) BeanInstantiator.of(k).run());
366      }
367
368      /**
369       * Returns <jk>true</jk> if the method has a parameter that produces the request body (a {@code @Content}
370       * parameter, or a single unannotated parameter treated as the body), in which case a method-level
371       * {@code @Content(def)} constant body must not be applied independently.
372       */
373      private static boolean hasBodyParam(Method method) {
374         var params = method.getParameters();
375         for (var p : params)
376            if (p.getAnnotation(Content.class) != null)
377               return true;
378         return params.length == 1 && ! hasPartAnnotation(params[0]);
379      }
380
381      /** Returns <jk>true</jk> if the parameter carries a recognized HTTP-part annotation (non-body). */
382      private static boolean hasPartAnnotation(Parameter param) {
383         return param.getAnnotation(Path.class) != null
384            || param.getAnnotation(Query.class) != null
385            || param.getAnnotation(Header.class) != null
386            || param.getAnnotation(FormData.class) != null
387            || param.getAnnotation(Request.class) != null
388            || param.getAnnotation(PathRemainder.class) != null
389            || param.getAnnotation(Url.class) != null
390            || param.getAnnotation(Part.class) != null;
391      }
392
393      /**
394       * Applies always-applied constant parts (header/query/form-data) to the request, layering method-level
395       * constants on top of interface-level constants.
396       *
397       * <p>
398       * Both scopes are merged into a single name-keyed map (preserving declaration order) so that, when the same
399       * name is declared at both scopes, the method-level value wins (most-specific).  The merged constants are
400       * emitted before parameter binding so that a caller-supplied parameter value of the same name composes with
401       * (rather than erases) the constant.
402       *
403       * @param adder Sink that records a single name/value part on the request.
404       * @param interfaceLevel Interface-level constants (applied first; lower precedence).
405       * @param methodLevel Method-level constants (override interface-level entries of the same name).
406       */
407      private static void applyConstants(BiConsumer<String,String> adder, List<Map.Entry<String,String>> interfaceLevel, List<Map.Entry<String,String>> methodLevel) {
408         if (interfaceLevel.isEmpty() && methodLevel.isEmpty())
409            return;
410         var merged = new LinkedHashMap<String,String>();
411         for (var e : interfaceLevel)
412            merged.put(e.getKey(), e.getValue());
413         for (var e : methodLevel)
414            merged.put(e.getKey(), e.getValue());
415         merged.forEach(adder);
416      }
417
418      /**
419       * Applies the always-applied constant <i>headers</i> (interface-level then method-level), with the
420       * precedence rule that a dedicated {@code contentType}/{@code accept} attribute wins over a constant
421       * {@code Content-Type}/{@code Accept} header of the same name.
422       *
423       * <p>
424       * The two scopes are merged (method-level wins on name collisions) and emitted in declaration order, except a
425       * constant {@code Content-Type} is dropped when {@code skipContentType} is set (the dedicated {@code contentType}
426       * attribute supplies it via the serialized body) and a constant {@code Accept} is dropped when {@code skipAccept}
427       * is set (the dedicated {@code accept} attribute supplies it after binding).  Dropping rather than emitting the
428       * constant guarantees a single header with the attribute's value.
429       */
430      private void applyConstantHeaders(RestRequest req, List<Map.Entry<String,String>> interfaceLevel, List<Map.Entry<String,String>> methodLevel, boolean skipContentType, boolean skipAccept) {
431         if (interfaceLevel.isEmpty() && methodLevel.isEmpty())
432            return;
433         var merged = new LinkedHashMap<String,String>();
434         for (var e : interfaceLevel)
435            merged.put(e.getKey(), e.getValue());
436         for (var e : methodLevel)
437            merged.put(e.getKey(), e.getValue());
438         merged.forEach((k, v) -> {
439            if (skipContentType && eqic("Content-Type", k))
440               return;
441            if (skipAccept && eqic(HEADER_ACCEPT, k))
442               return;
443            req.header(k, v);
444         });
445      }
446
447      /**
448       * Resolves the effective custom {@link HttpPartSerializer} for a parameter.
449       *
450       * <p>
451       * Precedence (most-specific first): parameter-level {@code @HttpPartMarshalling(serializer=...)} on the
452       * parameter itself, then the method-level default ({@link RrpcInterfaceMethodMeta#getPartSerializer()}), then
453       * the interface-level default ({@link RrpcInterfaceMeta#getPartSerializer()}).  Returns <jk>null</jk> when no
454       * {@code @HttpPartMarshalling} serializer is in effect, in which case the default OpenAPI part serializer is
455       * used (no-regression behavior).
456       *
457       * @param methodMeta The method metadata (method/interface-level defaults).
458       * @param param The method parameter (parameter-level annotation).
459       * @return The resolved part serializer, or <jk>null</jk> to use the default.
460       */
461      private HttpPartSerializer partSerializer(RrpcInterfaceMethodMeta methodMeta, Parameter param) {
462         var pm = param.getAnnotation(HttpPartMarshalling.class);
463         var s = pm == null ? null : RrpcInterfaceMethodMeta.resolvePartSerializer(pm.serializer());
464         if (s == null)
465            s = methodMeta.getPartSerializer();
466         if (s == null)
467            s = meta.getPartSerializer();
468         return s;
469      }
470
471      /**
472       * Binds a single method argument to the outgoing request based on its HTTP-part annotation.
473       *
474       * <p>
475       * Supports {@code @Path}, {@code @Query}, {@code @Header}, {@code @FormData}, {@code @Content}, and
476       * {@code @Request}.  {@code @Query}/{@code @Header}/{@code @FormData} honor dynamic name/value expansion
477       * (Map / {@code "*"} / part-list / bean) and parameter-level {@code def()} defaults for {@code null} args.
478       */
479      private void bindParam(RestRequest req, RrpcInterfaceMethodMeta methodMeta, Parameter param, Object arg, boolean soleParam, BodyFormat bodyFormat) throws IOException {
480         // @Url parameters supply the request URL (handled in resolveEffectiveUrl), not a part/body.
481         if (param.getAnnotation(Url.class) != null)
482            return;
483
484         // @Part parameters are assembled into the multipart body (see bindMultipartBody), not bound here.
485         if (param.getAnnotation(Part.class) != null)
486            return;
487
488         // Resolve the effective part serializer (param-level › method-level › interface-level),
489         // falling back to the default OpenAPI part serializer when no @HttpPartMarshalling is in effect.
490         var serializer = partSerializer(methodMeta, param);
491
492         var path = param.getAnnotation(Path.class);
493         if (path != null) {
494            var name = firstNonEmpty(path.value(), path.name(), param.getName());
495            // A parameter-level default (when one is set) wins, otherwise the method-level path default applies.
496            var pathDef = NONE.equals(path.def()) ? null : path.def();
497            var value = arg != null ? arg : firstNonEmpty(pathDef, methodMeta.getPathDefault(name));
498            if (value != null)
499               req.pathData(name, serializePart(HttpPartType.PATH, HttpPartSchema.create(path, null), value, serializer));
500            return;
501         }
502
503         var query = param.getAnnotation(Query.class);
504         if (query != null) {
505            var def = firstNonEmpty(query.def(), methodMeta.getQueryDefault(firstNonEmpty(query.value(), query.name(), param.getName())));
506            bindParts(HttpPartType.QUERY, HttpPartSchema.create(query, null), query.value(), query.name(), def, arg, param.getName(), serializer, req::queryData);
507            return;
508         }
509
510         var header = param.getAnnotation(Header.class);
511         if (header != null) {
512            var def = firstNonEmpty(header.def(), methodMeta.getHeaderDefault(firstNonEmpty(header.value(), header.name(), param.getName())));
513            bindParts(HttpPartType.HEADER, HttpPartSchema.create(header, null), header.value(), header.name(), def, arg, param.getName(), serializer, req::header);
514            return;
515         }
516
517         var formData = param.getAnnotation(FormData.class);
518         if (formData != null) {
519            var def = firstNonEmpty(formData.def(), methodMeta.getFormDataDefault(firstNonEmpty(formData.value(), formData.name(), param.getName())));
520            bindParts(HttpPartType.FORMDATA, HttpPartSchema.create(formData, null), formData.value(), formData.name(), def, arg, param.getName(), serializer, req::formData);
521            return;
522         }
523
524         var request = param.getAnnotation(Request.class);
525         if (request != null) {
526            if (arg != null)
527               bindRequestBean(req, arg);
528            return;
529         }
530
531         // A path-remainder parameter appends the part-serialized value as the trailing path remainder.
532         var pathRemainder = param.getAnnotation(PathRemainder.class);
533         if (pathRemainder != null) {
534            if (arg != null)
535               req.pathData("/*", serializePart(HttpPartType.PATH, HttpPartSchema.create(pathRemainder, null), arg, serializer));
536            return;
537         }
538
539         if (param.getAnnotation(Content.class) != null) {
540            // A null body argument falls back to the parameter-level content default, then to the method-level content default.
541            var content = param.getAnnotation(Content.class);
542            Object body = arg != null ? arg : firstNonEmpty(content.def(), methodMeta.getContentDefault());
543            if (body != null)
544               withContentBody(req, body, bodyFormat);
545            return;
546         }
547
548         if (soleParam && arg != null)
549            withContentBody(req, arg, bodyFormat);
550      }
551
552      /**
553       * Binds a {@code @Query}/{@code @Header}/{@code @FormData} argument as one or more name/value parts.
554       *
555       * <p>
556       * Single values are serialized via the configured {@link HttpPartSerializer} honoring the resolved
557       * {@link HttpPartSchema} ({@code @Schema} format / collection-format / {@code skipIfEmpty}); dynamic
558       * {@code "*"}/blank-name arguments expand into multiple parts.
559       *
560       * @param partType The HTTP part category (query/header/form-data).
561       * @param schema The resolved part schema (from the annotation + {@code @Schema}).
562       * @param annValue The annotation {@code value()} (may be blank, or {@code "*"} for dynamic expansion).
563       * @param annName The annotation {@code name()} (may be blank).
564       * @param def The parameter-level default applied when {@code arg} is <jk>null</jk>.
565       * @param arg The argument value.
566       * @param fallbackName The name to use when the annotation specifies none (param or bean-property name).
567       * @param serializer The custom {@link HttpPartSerializer} to use, or <jk>null</jk> for the default.
568       * @param adder Sink that records a single name/value part on the request.
569       */
570      @SuppressWarnings({
571         "java:S107" // The 9 parameters form one cohesive HTTP-part binding descriptor (type/schema/name/default/value/serializer/sink) threaded through internal dispatch; a holder object would add indirection without improving this reflective-proxy code.
572      })
573      private static void bindParts(HttpPartType partType, HttpPartSchema schema, String annValue, String annName, String def, Object arg, String fallbackName, HttpPartSerializer serializer, BiConsumer<String,String> adder) {
574         var explicit = firstNonEmpty(annValue, annName);
575         if (arg == null) {
576            if (def != null && ! def.isEmpty())
577               adder.accept(eq(explicit, "*") ? fallbackName : firstNonEmpty(explicit, fallbackName), def);
578            return;
579         }
580         if (eq(explicit, "*") || (explicit == null && isExpandable(arg))) {
581            expandPairs(partType, arg, serializer, adder);
582            return;
583         }
584         if (schema != null && schema.isSkipIfEmpty() && isEmptyArg(arg))
585            return;
586         var serialized = serializePart(partType, schema, arg, serializer);
587         if (serialized == null)
588            return;
589         if (serialized.isEmpty() && schema != null && schema.isSkipIfEmpty())
590            return;
591         adder.accept(firstNonEmpty(explicit, fallbackName), serialized);
592      }
593
594      /** Returns <jk>true</jk> if the argument is an empty string/collection/array/map (for {@code skipIfEmpty}). */
595      private static boolean isEmptyArg(Object arg) {
596         if (arg == null)
597            return true;
598         if (arg instanceof CharSequence arg2)
599            return arg2.isEmpty();
600         if (arg instanceof Map<?,?> m)
601            return m.isEmpty();
602         if (arg instanceof Collection<?> c)
603            return c.isEmpty();
604         if (arg.getClass().isArray())
605            return Array.getLength(arg) == 0;
606         return false;
607      }
608
609      /**
610       * Expands a dynamic argument (Map / {@link PartList} / {@link HttpHeaderList} / bean) into discrete
611       * name/value parts.  Map/bean values are part-serialized; {@link PartList}/{@link HttpHeaderList} entries
612       * are already string-valued and passed through.
613       */
614      private static void expandPairs(HttpPartType partType, Object arg, HttpPartSerializer serializer, BiConsumer<String,String> adder) {
615         if (arg instanceof Map<?,?> m) {
616            m.forEach((k, v) -> { if (k != null && v != null) adder.accept(String.valueOf(k), serializePart(partType, null, v, serializer)); });
617         } else if (arg instanceof PartList arg2) {
618            for (var p : arg2)
619               if (p.getValue() != null)
620                  adder.accept(p.getName(), p.getValue());
621         } else if (arg instanceof HttpHeaderList arg2) {
622            for (var h : arg2)
623               if (h.getValue() != null)
624                  adder.accept(h.getName(), h.getValue());
625         } else {
626            for (var e : MarshallingContext.DEFAULT.toBeanMap(arg).entrySet())
627               if (e.getValue() != null)
628                  adder.accept(e.getKey(), serializePart(partType, null, e.getValue(), serializer));
629         }
630      }
631
632      /**
633       * Serializes an HTTP-part value, honoring the supplied schema (collection format, value format, enums, etc.).
634       * Returns the non-URL-encoded string form.
635       *
636       * <p>
637       * When {@code serializer} is non-<jk>null</jk> (a custom {@code @HttpPartMarshalling} serializer), it is
638       * used; otherwise the default OpenAPI part serializer is used (no-regression behavior).
639       */
640      private static String serializePart(HttpPartType partType, HttpPartSchema schema, Object value, HttpPartSerializer serializer) {
641         try {
642            var session = serializer != null ? serializer.getPartSession() : OpenApiSerializer.DEFAULT.getPartSession();
643            return session.write(partType, schema, value);
644         } catch (Exception e) {
645            throw rex(e, "Could not serialize HTTP %s part value of type %s", partType, value == null ? "null" : cn(value));
646         }
647      }
648
649      /**
650       * Binds the HTTP-part-annotated getters of a {@code @Request} bean as discrete request parts.
651       */
652      private static void bindRequestBean(RestRequest req, Object bean) {
653         for (var m : bean.getClass().getMethods()) {
654            if (m.getParameterCount() != 0 || m.getDeclaringClass() == Object.class)
655               continue;
656            bindRequestBeanMethod(req, bean, m);
657         }
658      }
659
660      /**
661       * Binds a single HTTP-part-annotated getter of a {@code @Request} bean as a discrete request part.
662       */
663      private static void bindRequestBeanMethod(RestRequest req, Object bean, Method m) {
664         var q = m.getAnnotation(Query.class);
665         var h = m.getAnnotation(Header.class);
666         var f = m.getAnnotation(FormData.class);
667         var p = m.getAnnotation(Path.class);
668         if (q == null && h == null && f == null && p == null)
669            return;
670         Object value;
671         try {
672            value = m.invoke(bean);
673         } catch (ReflectiveOperationException e) {
674            throw rex(e, "Could not read @Request bean property via %s", m.getName());
675         }
676         var prop = propertyName(m.getName());
677         // @Request bean part serialization keeps the default OpenAPI serializer (custom per-part serializers are
678         // scoped to direct method parameters; passing null preserves byte-for-byte behavior).
679         if (q != null)
680            bindParts(HttpPartType.QUERY, HttpPartSchema.create(q, null), q.value(), q.name(), q.def(), value, prop, null, req::queryData);
681         else if (h != null)
682            bindParts(HttpPartType.HEADER, HttpPartSchema.create(h, null), h.value(), h.name(), h.def(), value, prop, null, req::header);
683         else if (f != null)
684            bindParts(HttpPartType.FORMDATA, HttpPartSchema.create(f, null), f.value(), f.name(), f.def(), value, prop, null, req::formData);
685         else if (value != null)
686            req.pathData(firstNonEmpty(p.value(), p.name(), prop), serializePart(HttpPartType.PATH, HttpPartSchema.create(p, null), value, null));
687      }
688
689      /**
690       * Returns <jk>true</jk> if a blank-named part argument should be expanded into multiple name/value parts
691       * (Map / {@link PartList} / {@link HttpHeaderList} / bean) rather than serialized as a single value.
692       */
693      private static boolean isExpandable(Object arg) {
694         return arg instanceof Map || arg instanceof PartList || arg instanceof HttpHeaderList || isBean(arg);
695      }
696
697      private static boolean isBean(Object arg) {
698         return ! (arg instanceof CharSequence || arg instanceof Number || arg instanceof Boolean
699            || arg instanceof Character || arg instanceof Enum || isDateTime(arg)
700            || arg instanceof Collection || arg.getClass().isArray());
701      }
702
703      /** Derives a bean-property name from a getter name (e.g. {@code getFoo} &rarr; {@code foo}). */
704      private static String propertyName(String methodName) {
705         if (methodName.startsWith("get") && methodName.length() > 3)
706            return Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4);
707         if (methodName.startsWith("is") && methodName.length() > 2)
708            return Character.toLowerCase(methodName.charAt(2)) + methodName.substring(3);
709         return methodName;
710      }
711
712      /**
713       * Executes the request and processes the return value, applying safe automatic retries when
714       * the method opts in and all safety gates pass.
715       *
716       * <p>
717       * <b>Retry policy.</b> When a positive retry count is in effect and the return mode + verb + body permit it,
718       * each attempt rebuilds the request via {@code reqSupplier} (so a fresh, re-bound request is resent) and runs
719       * it; a connection failure ({@link TransportException}) or a retryable status ({@code 429}/{@code 5xx}) triggers
720       * another attempt (after a short exponential backoff) until the cap is reached.  Safety gates (all hard):
721       * <ul>
722       *    <li>Only idempotent verbs (GET/PUT/DELETE/HEAD) auto-retry; POST/PATCH require {@code retryNonIdempotent}.
723       *    <li>A non-repeatable body ({@link RestRequest#isBodyRepeatable()} == <jk>false</jk>) is never retried.
724       *    <li>Streaming return modes ({@link RemoteReturn#RESPONSE}, raw {@link InputStream}/{@link Reader}, cursors)
725       *       and wrapper returns ({@link Optional}/{@link Future}/{@link CompletableFuture}) are never retried.
726       * </ul>
727       */
728      private Object processReturn(RequestSupplier reqSupplier, RrpcInterfaceMethodMeta methodMeta, Method method, boolean throwOnError, String acceptFallback) throws Exception {
729         var returnMode = methodMeta.getReturnType();
730         var returnType = method.getReturnType();
731         var genericReturnType = method.getGenericReturnType();
732
733         var maxRetries = (isRetryableMode(returnMode, returnType) && isRetryableVerb(methodMeta)) ? effectiveRetries(methodMeta) : 0;
734         if (maxRetries == 0)
735            return processReturnOnce(reqSupplier.get(), returnMode, method, throwOnError, acceptFallback);
736
737         var attempt = 0;
738         while (true) {
739            var req = reqSupplier.get();
740            // Non-repeatable body: refuse to retry (resending a consumed stream would corrupt the call).
741            if (attempt == 0 && ! req.isBodyRepeatable())
742               return processReturnOnce(req, returnMode, method, throwOnError, acceptFallback);
743            var shouldRetry = false;
744            RestResponse resp = null;
745            try {
746               resp = req.run();
747            } catch (TransportException e) {
748               if (attempt++ >= maxRetries)
749                  throw e;
750               backoff(attempt);
751               shouldRetry = true;
752            }
753            if (! shouldRetry && resp != null && attempt < maxRetries && isRetryableStatus(resp.getStatusCode())) {
754               resp.close();
755               attempt++;
756               backoff(attempt);
757               shouldRetry = true;
758            }
759            if (shouldRetry)
760               continue;
761            try (var r = resp) { // HTT - exception during close() branch
762               return materializeResponse(r, returnMode, returnType, genericReturnType, method, throwOnError, acceptFallback);
763            }
764         }
765      }
766
767      /** Executes a single (non-retried) attempt, including streaming / cursor / future / RESPONSE return modes. */
768      private Object processReturnOnce(RestRequest req, RemoteReturn returnMode, Method method, boolean throwOnError, String acceptFallback) throws Exception {
769         var returnType = method.getReturnType();
770         var genericReturnType = method.getGenericReturnType();
771         return switch (returnMode) {
772         case BODY -> processBody(req, returnType, genericReturnType, method, throwOnError, acceptFallback);
773         case RESPONSE -> req.run(); // caller must close
774         default -> {
775            try (var resp = req.run()) { // HTT - exception during close() branch
776               yield materializeResponse(resp, returnMode, returnType, genericReturnType, method, throwOnError, acceptFallback);
777            }
778         }
779         };
780      }
781
782      /** Materializes the terminal value for the buffered return modes (BODY/BEAN/STATUS/NONE) from a run response. */
783      private Object materializeResponse(RestResponse resp, RemoteReturn returnMode, Class<?> returnType, Type genericReturnType, Method method, boolean throwOnError, String acceptFallback) throws Exception {
784         return switch (returnMode) {
785         case BODY -> materializeBufferedBody(resp, returnType, genericReturnType, method, throwOnError, acceptFallback);
786         case BEAN -> {
787            // HTTP-response bean (e.g. Ok / NotFound): materialize from status + body rather than parsing.
788            throwIfError(resp, method, throwOnError);
789            yield instantiateHttpType(returnType, resp.getBodyAsString());
790         }
791         case STATUS -> {
792            var sc = resp.getStatusCode();
793            if (returnType == int.class || returnType == Integer.class)
794               yield sc;
795            if (returnType == boolean.class || returnType == Boolean.class)
796               yield sc < 400;
797            yield sc;
798         }
799         case NONE -> null;
800         default -> throw new IllegalStateException("Return mode " + returnMode + " is not a buffered return mode"); // HTT: RESPONSE (the only other RemoteReturn value) is filtered out by isRetryableMode()/processReturnOnce() before this method is ever called; unreachable for any current enum value.
801         };
802      }
803
804      /** Returns the effective retry count: a positive method-level value overrides the interface-level default. */
805      private int effectiveRetries(RrpcInterfaceMethodMeta methodMeta) {
806         return methodMeta.getRetries() > 0 ? methodMeta.getRetries() : meta.getRetries();
807      }
808
809      /** Returns <jk>true</jk> if the verb may auto-retry: idempotent verbs always, POST/PATCH only when opted in. */
810      private boolean isRetryableVerb(RrpcInterfaceMethodMeta methodMeta) {
811         if (isOneOf(methodMeta.getHttpMethod(), "GET", "PUT", "DELETE", "HEAD"))
812            return true;
813         return methodMeta.isRetryNonIdempotent() || meta.isRetryNonIdempotent();
814      }
815
816      /** Returns <jk>true</jk> if the return mode/type is a buffered (retryable) shape, not a streaming/wrapper one. */
817      private static boolean isRetryableMode(RemoteReturn returnMode, Class<?> returnType) {
818         if (returnMode == RemoteReturn.RESPONSE)
819            return false;
820         if (returnType == InputStream.class || returnType == Reader.class || returnType == Optional.class
821               || returnType == CompletableFuture.class || returnType == Future.class)
822            return false;
823         return ! RecordReader.class.isAssignableFrom(returnType);
824      }
825
826      /** Retryable HTTP statuses: {@code 429} (Too Many Requests) and all {@code 5xx} server errors. */
827      private static boolean isRetryableStatus(int statusCode) {
828         return statusCode == 429 || statusCode >= 500;
829      }
830
831      /** Sleeps a short exponential backoff ({@code 50ms * 2^(attempt-1)}, capped at {@code 1s}) before a retry. */
832      private static void backoff(int attempt) {
833         try {
834            Thread.sleep(Math.min(50L << (attempt - 1), 1000L));
835         } catch (InterruptedException e) {
836            Thread.currentThread().interrupt();
837         }
838      }
839
840      /**
841       * Handles {@link RemoteReturn#BODY} returns, including {@link Optional} and {@link CompletableFuture}/{@link Future}
842       * wrappers and HTTP-response beans.
843       */
844      private Object processBody(RestRequest req, Class<?> returnType, Type genericReturnType, Method method, boolean throwOnError, String acceptFallback) throws Exception {
845         if (returnType == Optional.class) {
846            var inner = innerType(genericReturnType);
847            return o(processBodyValue(req, rawClass(inner), inner, method, throwOnError, acceptFallback));
848         }
849         if (returnType == CompletableFuture.class || returnType == Future.class) {
850            var inner = innerType(genericReturnType);
851            return CompletableFuture.completedFuture(processBodyValue(req, rawClass(inner), inner, method, throwOnError, acceptFallback));
852         }
853         return processBodyValue(req, returnType, genericReturnType, method, throwOnError, acceptFallback);
854      }
855
856      /** Runs the request and materializes a single (unwrapped) body value. */
857      private Object processBodyValue(RestRequest req, Class<?> returnType, Type genericReturnType, Method method, boolean throwOnError, String acceptFallback) throws Exception {
858         // Cursor return types stream over the live response body; the caller owns the cursor (and the response it holds).
859         if (RecordReader.class.isAssignableFrom(returnType))
860            return processCursor(req, returnType, method, throwOnError);
861         // A raw-stream return hands the caller the LIVE response stream — the response must NOT be
862         // closed on success here (the old code returned it from inside try-with-resources, closing it before the
863         // caller could read).  The caller owns the returned stream; closing it releases the connection.
864         if (returnType == InputStream.class)
865            return processStreamReturn(req, method, throwOnError);
866         // A Reader return is a lazy reader over the same live response stream (same lifecycle).
867         if (returnType == Reader.class)
868            return processReaderReturn(req, method, throwOnError);
869         try (var resp = req.run()) { // HTT - exception during close() branch
870            return materializeBufferedBody(resp, returnType, genericReturnType, method, throwOnError, acceptFallback);
871         }
872      }
873
874      /** Materializes a buffered (non-streaming) body value from an already-run response. */
875      private Object materializeBufferedBody(RestResponse resp, Class<?> returnType, Type genericReturnType, Method method, boolean throwOnError, String acceptFallback) throws Exception {
876         throwIfError(resp, method, throwOnError);
877         if (returnType == void.class || returnType == Void.class)
878            return null;
879         if (returnType == String.class)
880            return resp.getBodyAsString();
881         if (returnType == byte[].class)
882            return resp.body().asBytes();
883         if (BasicHttpResponse.class.isAssignableFrom(returnType) || BasicHttpException.class.isAssignableFrom(returnType))
884            return instantiateHttpType(returnType, resp.getBodyAsString());
885         return parseBody(resp, genericReturnType, acceptFallback);
886      }
887
888      /**
889       * Runs the request and hands back the live response body as an {@link InputStream} the caller owns.
890       *
891       * <p>
892       * Mirrors the cursor lifecycle ({@link #processCursor}): on success the response is <b>not</b> closed &mdash; the
893       * returned stream wraps the live response body and closing it releases the connection (closes the body stream and
894       * the response, firing the transport close callback).  On any failure before the stream is handed back, the
895       * response is closed.  A body-less response ({@code null} stream) is released immediately and returns <jk>null</jk>.
896       */
897      private InputStream processStreamReturn(RestRequest req, Method method, boolean throwOnError) throws Exception {
898         var resp = req.run();
899         var ok = false;
900         try {
901            throwIfError(resp, method, throwOnError);
902            var stream = resp.getBodyStream();
903            if (stream == null) {
904               ok = true;
905               resp.close();
906               return null;
907            }
908            var result = new ResponseClosingInputStream(stream, resp);
909            ok = true;
910            return result;
911         } finally {
912            if (! ok)
913               resp.close();
914         }
915      }
916
917      /**
918       * Runs the request and hands back the live response body as a {@link Reader} the caller owns.
919       *
920       * <p>
921       * Same lifecycle as {@link #processStreamReturn}: the response is not closed on success; the returned reader wraps
922       * the live response stream (decoded as UTF-8) and closing it releases the connection.
923       */
924      private Reader processReaderReturn(RestRequest req, Method method, boolean throwOnError) throws Exception {
925         var resp = req.run();
926         var ok = false;
927         try {
928            throwIfError(resp, method, throwOnError);
929            var stream = resp.getBodyStream();
930            if (stream == null) {
931               ok = true;
932               resp.close();
933               return null;
934            }
935            var result = new ResponseClosingReader(new InputStreamReader(stream, StandardCharsets.UTF_8), resp);
936            ok = true;
937            return result;
938         } finally {
939            if (! ok)
940               resp.close();
941         }
942      }
943
944      /**
945       * A response-body {@link InputStream} that releases the owning {@link RestResponse} when closed.
946       *
947       * <p>
948       * Closing this stream closes the underlying body stream (returning the connection to the pool) and then closes the
949       * {@link RestResponse} (firing the transport close callback), so the connection is released regardless of whether
950       * the transport relies on the body stream or the close callback.
951       */
952      private static final class ResponseClosingInputStream extends FilterInputStream {
953         private final RestResponse response;
954
955         ResponseClosingInputStream(InputStream in, RestResponse response) {
956            super(in);
957            this.response = response;
958         }
959
960         @Override /* Closeable */
961         public void close() throws IOException {
962            try {
963               super.close();
964            } finally {
965               response.close();
966            }
967         }
968
969         @Override
970         public int read(byte[] buf, int off, int len) throws IOException {
971            if (len == 0) return 0;
972            int b = read();
973            if (b == -1) return -1;
974            buf[off] = (byte) b;
975            return 1;
976         }
977      }
978
979      /** A response-body {@link Reader} that releases the owning {@link RestResponse} when closed. */
980      private static final class ResponseClosingReader extends FilterReader {
981         private final RestResponse response;
982
983         ResponseClosingReader(Reader in, RestResponse response) {
984            super(in);
985            this.response = response;
986         }
987
988         @Override /* Closeable */
989         public void close() throws IOException {
990            try {
991               super.close();
992            } finally {
993               response.close();
994            }
995         }
996      }
997
998      /**
999       * Maps an error HTTP status (&ge;400) to a typed exception declared in the method's {@code throws} clause.
1000       *
1001       * <p>
1002       * A declared exception type matches when it extends {@link BasicHttpException} and its static {@code STATUS_CODE}
1003       * field equals the response status code (e.g. {@code 404} &rarr; {@link NotFound}).
1004       * If no declared type matches, the response is left for normal body handling.
1005       */
1006      private static void throwIfError(RestResponse resp, Method method, boolean throwOnError) throws Exception {
1007         var sc = resp.getStatusCode();
1008         if (sc < 400)
1009            return;
1010         for (var et : method.getExceptionTypes()) {
1011            if (BasicHttpException.class.isAssignableFrom(et) && httpStatusCode(et) == sc)
1012               throw (BasicHttpException) instantiateHttpType(et, resp.getBodyAsString());
1013         }
1014         // No declared exception type matched — if throwOnError is enabled, raise a generic
1015         // HTTP exception carrying the status code and response body rather than returning an error payload.
1016         if (throwOnError)
1017            throw new BasicHttpException(sc, resp.getBodyAsString());
1018      }
1019
1020      /** Returns the static {@code STATUS_CODE} field of an HTTP response/exception type, or {@code -1} if absent. */
1021      private static int httpStatusCode(Class<?> c) {
1022         try {
1023            return c.getField("STATUS_CODE").getInt(null);
1024         } catch (ReflectiveOperationException e) {
1025            return -1;
1026         }
1027      }
1028
1029      /**
1030       * Instantiates an HTTP response/exception bean, preferring a {@code (String body)} constructor and falling back
1031       * to the no-arg constructor (status code is intrinsic to the type).
1032       */
1033      private static Object instantiateHttpType(Class<?> c, String body) {
1034         try {
1035            return newHttpTypeInstance(c, body);
1036         } catch (ReflectiveOperationException e) {
1037            throw rex(e, "Could not instantiate HTTP response/exception type %s", c.getName());
1038         }
1039      }
1040
1041      /** Instantiates {@code c} preferring a {@code (String body)} constructor, falling back to the no-arg constructor. */
1042      private static Object newHttpTypeInstance(Class<?> c, String body) throws ReflectiveOperationException {
1043         try {
1044            return c.getConstructor(String.class).newInstance(body);
1045         } catch (NoSuchMethodException e) {
1046            return c.getConstructor().newInstance();
1047         }
1048      }
1049
1050      /** Returns the raw {@link Class} of a possibly-parameterized type. */
1051      private static Class<?> rawClass(Type t) {
1052         if (t instanceof Class<?> c)
1053            return c;
1054         if (t instanceof ParameterizedType t2)
1055            return (Class<?>) t2.getRawType();
1056         return Object.class;
1057      }
1058
1059      /** Returns the first type argument of a parameterized wrapper (e.g. {@code Optional<T>} &rarr; {@code T}). */
1060      private static Type innerType(Type t) {
1061         if (t instanceof ParameterizedType t2) {
1062            var args = t2.getActualTypeArguments();
1063            if (args.length > 0)
1064               return args[0];
1065         }
1066         return Object.class;
1067      }
1068
1069      private static RestRequest withContentBody(RestRequest req, Object arg, BodyFormat bodyFormat) throws IOException {
1070         if (arg instanceof HttpBody arg2)
1071            return req.body(arg2);
1072         if (arg instanceof String arg2)
1073            return req.bodyString(arg2);
1074         // Stream the Reader straight to the wire instead of draining it into a String first.
1075         if (arg instanceof Reader arg2)
1076            return req.body(ReaderBody.of(arg2));
1077         // When a contentType attribute is in effect, serialize the POJO with the selected serializer
1078         // and send the overridden Content-Type label (replacing the serializer's default — exactly one header).
1079         if (bodyFormat.isSet())
1080            return req.body(SerializerBody.of(bodyFormat.serializer(), arg, bodyFormat.contentType()));
1081         return req.body(arg);
1082      }
1083
1084      /**
1085       * Assembles a {@code multipart/form-data} request body from the method's {@link Part @Part} parameters.
1086       *
1087       * <p>
1088       * A thin adapter onto the existing streaming {@link MultipartBody}: each non-<jk>null</jk> {@code @Part}
1089       * argument becomes one part whose name/filename/content-type come from the annotation and whose body is mapped
1090       * from the argument type by {@link #toPartBody(Object, String)} (file/stream/reader/byte[] parts stream, reusing
1091       * the streaming bodies; beans are serialized).  The {@code multipart/form-data} {@code Content-Type} (with
1092       * its boundary) is supplied by {@link MultipartBody#getContentType()} when the request is built.
1093       */
1094      private void bindMultipartBody(RestRequest req, Method method, Object[] args) {
1095         var builder = MultipartBody.builder();
1096         var params = method.getParameters();
1097         for (var i = 0; i < params.length; i++) {
1098            var part = params[i].getAnnotation(Part.class);
1099            if (part == null)
1100               continue;
1101            var arg = (args == null || i >= args.length) ? null : args[i];
1102            if (arg != null) {
1103               var name = firstNonEmpty(part.name(), part.value(), params[i].getName());
1104               var contentType = part.contentType().isEmpty() ? null : part.contentType();
1105               var fileName = firstNonEmpty(part.fileName().isEmpty() ? null : part.fileName(), defaultFileName(arg));
1106               builder.part(MultipartBody.MultipartPart.of(name, fileName, contentType, toPartBody(arg, contentType)));
1107            }
1108         }
1109         req.body(builder.build());
1110      }
1111
1112      /**
1113       * Maps a {@code @Part} argument to a streaming-or-buffered {@link HttpBody} based on its runtime type.
1114       *
1115       * <p>
1116       * {@link File}/{@link InputStream}/{@link Reader} and {@code byte[]} parts reuse the streaming bodies
1117       * (never pre-buffered); scalar values become a text {@link StringBody}; an {@link HttpBody} is used directly;
1118       * any other object (a bean) is streamed through the client's default {@link Serializer}.
1119       */
1120      private HttpBody toPartBody(Object arg, String contentType) {
1121         if (arg instanceof HttpBody arg2)
1122            return arg2;
1123         if (arg instanceof byte[] bytes)
1124            return ByteArrayBody.of(bytes, firstNonEmpty(contentType, MEDIA_OCTET_STREAM));
1125         if (arg instanceof File arg2)
1126            return FileBody.of(arg2, firstNonEmpty(contentType, MEDIA_OCTET_STREAM));
1127         if (arg instanceof InputStream arg2)
1128            return StreamBody.of(arg2, firstNonEmpty(contentType, MEDIA_OCTET_STREAM));
1129         if (arg instanceof Reader arg2)
1130            return ReaderBody.of(arg2, firstNonEmpty(contentType, "text/plain; charset=UTF-8"));
1131         if (isScalarPart(arg))
1132            return StringBody.of(arg.toString(), firstNonEmpty(contentType, "text/plain; charset=UTF-8"));
1133         // Bean part: stream it through the client's default serializer (no full in-memory materialization).
1134         var s = client.getDefaultSerializer().orElseThrow(() -> new IllegalStateException(
1135            "No default serializer is configured on the client.  Configure one via RestClient.Builder.defaultSerializer(...)."));
1136         return contentType != null ? SerializerBody.of(s, arg, contentType) : SerializerBody.of(s, arg);
1137      }
1138
1139      /** Returns the default filename for a {@code @Part} argument: a {@link File}'s own name, else <jk>null</jk>. */
1140      private static String defaultFileName(Object arg) {
1141         return arg instanceof File f ? f.getName() : null;
1142      }
1143
1144      /** Returns <jk>true</jk> if the {@code @Part} argument is a scalar that should be sent as a text field. */
1145      private static boolean isScalarPart(Object arg) {
1146         return arg instanceof CharSequence || arg instanceof Number || arg instanceof Boolean
1147            || arg instanceof Character || arg instanceof Enum || isDateTime(arg);
1148      }
1149
1150      /** Returns <jk>true</jk> for {@link Date}, {@link Calendar} and {@code java.time} values (Instant, LocalDate, Duration, Period, ...), which are scalars. */
1151      private static boolean isDateTime(Object arg) {
1152         return arg instanceof Date || arg instanceof Calendar || arg instanceof Temporal || arg instanceof TemporalAmount;
1153      }
1154
1155      /**
1156       * Runs the request and opens a token/record-streaming cursor over the (live) response body.
1157       *
1158       * <p>
1159       * Unlike the buffered body paths, the response is <b>not</b> closed here on success: the returned cursor reads
1160       * directly from the response stream and the caller owns it (close the cursor when done).  On any failure before
1161       * the cursor is handed back, the response is closed.
1162       */
1163      private Object processCursor(RestRequest req, Class<?> returnType, Method method, boolean throwOnError) throws Exception {
1164         var resp = req.run();
1165         var ok = false;
1166         try {
1167            throwIfError(resp, method, throwOnError);
1168            var cursor = resp.body().asCursor(returnType);
1169            ok = true;
1170            return cursor;
1171         } finally {
1172            if (! ok)
1173               resp.close();
1174         }
1175      }
1176
1177      /**
1178       * Parses a response body for an {@code @Remote}-proxy method return value.
1179       *
1180       * <p>
1181       * Unlike {@link ResponseBody#as(Parser, Class)},
1182       * which always surfaces a parse failure as an {@code IOException}, this method applies a deliberate proxy-only
1183       * leniency: when the declared return type is {@code Object}, a {@link ParseException} is swallowed and the raw
1184       * response body string is returned as-is. For any other declared return type the {@code ParseException}
1185       * propagates unchanged.
1186       *
1187       * <p>
1188       * Response parsing remains driven by the response {@code Content-Type} (the server is
1189       * authoritative about what it actually sent).  The {@code acceptFallback} media type is consulted only when the
1190       * response is unlabeled or its {@code Content-Type} matches no registered parser (see {@link #selectParser}).
1191       */
1192      private static Object parseBody(RestResponse resp, Type returnType, String acceptFallback) throws Exception {
1193         var body = resp.getBodyAsString();
1194         if (body == null)
1195            return null;
1196         var h = resp.getFirstHeader("Content-Type");
1197         var parser = selectParser(resp, h == null ? null : h.value(), acceptFallback);
1198         try {
1199            return parser.read(body, returnType);
1200         } catch (ParseException e) {
1201            if (returnType == Object.class)
1202               return body;
1203            throw e;
1204         }
1205      }
1206
1207      /**
1208       * Selects the response parser honoring the locked precedence: the response {@code Content-Type}
1209       * is authoritative; the {@code accept} media type is only a fallback.
1210       *
1211       * <ol>
1212       *    <li>If the response {@code Content-Type} matches a registered parser, use it.
1213       *    <li>Otherwise (response unlabeled or its type matched nothing), use the parser matching the {@code accept}
1214       *       media type, if one is registered.
1215       *    <li>Otherwise fall back to the client's explicitly-configured default parser
1216       *       ({@link RestClient#getMatchingParser(String)}); if none is configured this throws
1217       *       <c>415 Unsupported Media Type</c> (there is no implicit JSON fallback).
1218       * </ol>
1219       *
1220       * @param resp The response (source of the client + parsers).
1221       * @param responseContentType The response {@code Content-Type} header value. May be <jk>null</jk>.
1222       * @param acceptFallback The {@code accept} media type fallback. May be <jk>null</jk>/empty.
1223       * @return The parser to use. Never <jk>null</jk>.
1224       * @throws UnsupportedMediaType If no parser matches and no default parser is configured on the client.
1225       */
1226      private static Parser selectParser(RestResponse resp, String responseContentType, String acceptFallback) {
1227         var c = resp.getClient();
1228         var p = c.getParserForMediaType(responseContentType);
1229         if (p.isPresent())
1230            return p.get();
1231         if (isNotEmpty(acceptFallback)) {
1232            var ap = c.getParserForMediaType(acceptFallback);
1233            if (ap.isPresent())
1234               return ap.get();
1235         }
1236         return c.getMatchingParser(responseContentType).orElseThrow(() -> new UnsupportedMediaType(
1237            "No parser matched the response Content-Type '%s' and no default parser is configured on the client.", responseContentType));
1238      }
1239
1240      private static String combinePaths(String base, String method) {
1241         if (base.isEmpty())
1242            return method.isEmpty() ? "" : method;
1243         if (method.isEmpty())
1244            return base;
1245         // Avoid double slashes
1246         if (base.endsWith("/") && method.startsWith("/"))
1247            return base + method.substring(1);
1248         if (!base.endsWith("/") && !method.startsWith("/"))
1249            return base + "/" + method;
1250         return base + method;
1251      }
1252   }
1253}