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 * — 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} → {@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 — 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 (≥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} → {@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>} → {@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}