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.http.remote;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020import static org.apache.juneau.commons.utils.StringUtils.*;
021
022import java.lang.reflect.*;
023import java.util.*;
024import java.util.logging.*;
025
026import org.apache.juneau.commons.svl.*;
027import org.apache.juneau.marshall.httppart.*;
028import org.apache.juneau.rest.common.utils.*;
029
030/**
031 * Holds resolved metadata for an interface annotated with {@link Remote @Remote}.
032 *
033 * <p>
034 * Built once per interface class and cached for efficient proxy invocation.
035 * Use {@link #of(Class)} to obtain instances.
036 *
037 * <h5 class='section'>See Also:</h5><ul>
038 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/NextGenRestClient">Next-Generation REST Client</a>
039 * </ul>
040 *
041 * @since 9.2.1
042 */
043public final class RrpcInterfaceMeta {
044
045   private static final Map<Class<?>,RrpcInterfaceMeta> CACHE = new java.util.concurrent.ConcurrentHashMap<>();
046
047   private static final Logger LOG = Logger.getLogger("org.apache.juneau.http.remote");
048
049   /** Interfaces already warned about {@link Remote#headerList()}, to keep the build-time warning one-time. */
050   private static final Set<Class<?>> HEADERLIST_WARNED = java.util.concurrent.ConcurrentHashMap.newKeySet();
051
052   private final Class<?> iface;
053   private final String basePath;
054   // Interface-level base/host override: an annotation-declared substitute for the client root URL.
055   private final String baseUrl;
056   // Interface-level content negotiation: default accept + contentType media-type overrides.
057   private final String accept;
058   private final String contentType;
059   private final List<Map.Entry<String,String>> headers;
060   private final List<Map.Entry<String,String>> queryData;
061   private final List<Map.Entry<String,String>> formData;
062   // Interface-level custom part serializer: from @HttpPartMarshalling(serializer=...) on the interface.
063   private final HttpPartSerializer partSerializer;
064   // Interface-level cross-cutting call policy: defaults inherited by every method.
065   private final Class<?>[] interceptorClasses;
066   private final String timeout;
067   private final int retries;
068   private final boolean retryNonIdempotent;
069   private final boolean throwOnError;
070   private final boolean allowPrivateUrls;
071   private final Map<Method,RrpcInterfaceMethodMeta> methodMetas;
072   private final Map<String,RrpcInterfaceMethodMeta> methodMetasByPath;
073
074   private RrpcInterfaceMeta(Class<?> iface) {
075      this(iface, false);
076   }
077
078   /**
079    * Constructor.
080    *
081    * <p>
082    * Provides backwards compatibility with the classic {@code RrpcInterfaceMeta} signature; the {@code uri}
083    * argument is accepted for API parity and is currently ignored by this implementation.
084    *
085    * <p>
086    * Unlike {@link #of(Class)}, this constructor includes <i>all</i> public interface methods (matching the
087    * classic behavior), not just those annotated with the {@code @Remote*} annotations.
088    *
089    * @param iface The interface class. Must not be <jk>null</jk>.
090    * @param uri Reserved for compatibility with the classic constructor. May be <jk>null</jk>.
091    */
092   @SuppressWarnings({
093      "java:S1172" // 'uri' is part of the public constructor signature retained for API parity with the classic RrpcInterfaceMeta; intentionally ignored
094   })
095   public RrpcInterfaceMeta(Class<?> iface, String uri) {
096      this(reqnn("iface", iface), true);
097   }
098
099   private RrpcInterfaceMeta(Class<?> iface, boolean includeUnannotated) {
100      if (!iface.isInterface())
101         throw iaex("Class %s is not an interface", iface.getName());
102      var remote = iface.getAnnotation(Remote.class);
103
104      this.iface = iface;
105      this.basePath = buildBasePath(remote);
106      this.baseUrl = buildBaseUrl(remote);
107      this.accept = buildAccept(remote);
108      this.contentType = buildContentType(remote);
109      this.headers = buildHeaders(remote);
110      this.queryData = buildQueryData(remote);
111      this.formData = buildFormData(remote);
112      this.partSerializer = buildPartSerializer(iface);
113      // Interface-level cross-cutting policy defaults.
114      this.interceptorClasses = buildInterceptorClasses(remote);
115      this.timeout = buildTimeout(remote);
116      this.retries = buildRetries(remote);
117      this.retryNonIdempotent = buildRetryNonIdempotent(remote);
118      this.throwOnError = buildThrowOnError(remote);
119      this.allowPrivateUrls = buildAllowPrivateUrls(remote);
120      warnHeaderListUnsupported(iface, remote);
121
122      var metas = new LinkedHashMap<Method,RrpcInterfaceMethodMeta>();
123      for (var m : iface.getMethods()) {
124         var opt = buildMethodMeta(m);
125         if (opt.isPresent()) {
126            metas.put(m, opt.get());
127         } else if (includeUnannotated) {
128            metas.put(m, new RrpcInterfaceMethodMeta(m, "POST", buildSignaturePath(m), RemoteReturn.BODY));
129         }
130      }
131      this.methodMetas = u(metas);
132
133      var byPath = new LinkedHashMap<String,RrpcInterfaceMethodMeta>();
134      for (var v : metas.values())
135         byPath.put(v.getPath(), v);
136      this.methodMetasByPath = u(byPath);
137   }
138
139   private static String buildBasePath(Remote remote) {
140      if (remote == null)
141         return "";
142      return remote.path().isEmpty() ? remote.value() : remote.path();
143   }
144
145   private static String buildBaseUrl(Remote remote) {
146      return remote == null ? "" : VarResolver.DEFAULT.resolve(remote.baseUrl());
147   }
148
149   private static String buildAccept(Remote remote) {
150      return remote == null ? "" : VarResolver.DEFAULT.resolve(remote.accept());
151   }
152
153   private static String buildContentType(Remote remote) {
154      return remote == null ? "" : VarResolver.DEFAULT.resolve(remote.contentType());
155   }
156
157   private static List<Map.Entry<String,String>> buildQueryData(Remote remote) {
158      return remote == null ? List.of() : parseConstantParts(remote.queryData(), '=');
159   }
160
161   private static List<Map.Entry<String,String>> buildFormData(Remote remote) {
162      return remote == null ? List.of() : parseConstantParts(remote.formData(), '=');
163   }
164
165   private static HttpPartSerializer buildPartSerializer(Class<?> iface) {
166      var hpm = iface.getAnnotation(HttpPartMarshalling.class);
167      return hpm == null ? null : RrpcInterfaceMethodMeta.resolvePartSerializer(hpm.serializer());
168   }
169
170   private static Class<?>[] buildInterceptorClasses(Remote remote) {
171      return remote == null ? new Class<?>[0] : remote.interceptors();
172   }
173
174   private static String buildTimeout(Remote remote) {
175      return remote == null ? "" : remote.timeout();
176   }
177
178   private static int buildRetries(Remote remote) {
179      return remote == null ? 0 : remote.retries();
180   }
181
182   private static boolean buildRetryNonIdempotent(Remote remote) {
183      return remote != null && remote.retryNonIdempotent();
184   }
185
186   private static boolean buildThrowOnError(Remote remote) {
187      return remote != null && remote.throwOnError();
188   }
189
190   private static boolean buildAllowPrivateUrls(Remote remote) {
191      return remote != null && remote.allowPrivateUrls();
192   }
193
194   /**
195    * Emits a one-time build-time warning when {@link Remote#headerList()} is set on an interface used with the
196    * next-generation engine.
197    *
198    * <p>
199    * {@code headerList()} is a genuinely engine-specific member: its value type is a classic
200    * {@code org.apache.juneau.http.classic.header.HeaderList} subclass that is tied to the Apache-HttpClient
201    * transport, so the transport-agnostic next-generation engine cannot honor it.  Rather than silently ignore it,
202    * we warn (at most once per interface) and point the caller at the transport-agnostic {@link Remote#headers()}
203    * member instead.
204    *
205    * @param iface The proxy interface.
206    * @param remote The resolved {@link Remote} annotation (may be <jk>null</jk>).
207    */
208   private static void warnHeaderListUnsupported(Class<?> iface, Remote remote) {
209      if (remote == null || remote.headerList() == Void.class)
210         return;
211      if (HEADERLIST_WARNED.add(iface))
212         LOG.warning(() -> "@Remote member 'headerList' set on " + iface.getName()
213            + " is not honored by the next-generation REST-proxy engine: its value type is a classic"
214            + " Apache-HttpClient HeaderList that is transport-specific."
215            + "  Use the transport-agnostic headers() member instead.");
216   }
217
218   /**
219    * Resolves the interface-level constant headers declared via {@link Remote#headers()} and the client-version
220    * header derived from {@link Remote#version()}/{@link Remote#versionHeader()}.
221    *
222    * <p>
223    * Each {@code headers()} entry is a {@code "Name: value"} string (the colon-separated form), resolved through
224    * {@link VarResolver#DEFAULT} so values such as {@code "$S{sysprop}"} expand.  A non-empty {@code version()}
225    * appends a {@code Client-Version} header (or the name given by {@code versionHeader()}).
226    *
227    * <p>
228    * <b>Note:</b> {@link Remote#headerList()} is intentionally <i>not</i> honored by the next-gen engine — its value
229    * type is a classic {@code org.apache.juneau.http.classic.header.HeaderList} subclass that is specific to the
230    * Apache-HttpClient transport.  Use {@code headers()} for transport-agnostic constant headers.
231    */
232   private static List<Map.Entry<String,String>> buildHeaders(Remote remote) {
233      if (remote == null)
234         return List.of();
235      var l = new ArrayList<>(parseConstantParts(remote.headers(), ':'));
236      var version = VarResolver.DEFAULT.resolve(remote.version());
237      if (! version.isEmpty()) {
238         var vh = remote.versionHeader().isEmpty() ? "Client-Version" : VarResolver.DEFAULT.resolve(remote.versionHeader());
239         l.add(Map.entry(vh, version));
240      }
241      return u(l);
242   }
243
244   /**
245    * Parses an array of constant {@code "name<delim>value"} strings into resolved name/value entries.
246    *
247    * <p>
248    * Each entry is resolved through {@link VarResolver#DEFAULT} (so values such as {@code "$S{sysprop}"} expand), then
249    * split on the first occurrence of {@code delim} ({@code ':'} for the {@code "Name: value"} header form, {@code '='}
250    * for the {@code "name=value"} query/form-data form).  Entries with no delimiter are skipped.  Both the name and the
251    * value are trimmed.
252    *
253    * @param entries The raw annotation strings (may be empty).
254    * @param delim The name/value delimiter character.
255    * @return An unmodifiable list of resolved name/value entries. Never <jk>null</jk>, but may be empty.
256    */
257   static List<Map.Entry<String,String>> parseConstantParts(String[] entries, char delim) {
258      if (entries.length == 0)
259         return List.of();
260      var l = new ArrayList<Map.Entry<String,String>>();
261      for (var e : entries) {
262         var s = VarResolver.DEFAULT.resolve(e);
263         var i = s.indexOf(delim);
264         if (i != -1)
265            l.add(Map.entry(s.substring(0, i).trim(), s.substring(i + 1).trim()));
266      }
267      return u(l);
268   }
269
270   private static String buildSignaturePath(Method m) {
271      var sb = new StringBuilder(128);
272      sb.append(m.getName()).append('/');
273      Class<?>[] pt = m.getParameterTypes();
274      if (pt.length == 0)
275         return sb.toString();
276      sb.append('(');
277      for (var i = 0; i < pt.length; i++) {
278         if (i > 0)
279            sb.append(',');
280         appendTypeName(sb, pt[i]);
281      }
282      sb.append(')');
283      return sb.toString();
284   }
285
286   private static void appendTypeName(StringBuilder sb, Class<?> c) {
287      if (c.isArray()) {
288         appendTypeName(sb, c.getComponentType());
289         sb.append("[]");
290      } else {
291         sb.append(c.getName());
292      }
293   }
294
295   /**
296    * Returns the {@link RrpcInterfaceMeta} for the given interface, creating and caching it if necessary.
297    *
298    * @param iface The interface class. Must be annotated with {@link Remote}. Must not be <jk>null</jk>.
299    * @return The metadata. Never <jk>null</jk>.
300    * @throws IllegalArgumentException If the class is not an interface or not annotated with {@link Remote}.
301    */
302   public static RrpcInterfaceMeta of(Class<?> iface) {
303      reqnn("iface", iface);
304      if (!iface.isInterface())
305         throw iaex("Class %s is not an interface", iface.getName());
306      if (iface.getAnnotation(Remote.class) == null)
307         throw iaex("Class %s is not annotated with @Remote", iface.getName());
308      return CACHE.computeIfAbsent(iface, RrpcInterfaceMeta::new);
309   }
310
311   /**
312    * Returns the interface class.
313    *
314    * @return The interface. Never <jk>null</jk>.
315    */
316   public Class<?> getInterface() {
317      return iface;
318   }
319
320   /**
321    * Returns the Java class of this interface.
322    *
323    * <p>
324    * Alias for {@link #getInterface()} provided for backwards compatibility with the classic API.
325    *
326    * @return The Java class. Never <jk>null</jk>.
327    */
328   public Class<?> getJavaClass() {
329      return getInterface();
330   }
331
332   /**
333    * Returns the method metadata keyed by the resolved method path.
334    *
335    * @return An unmodifiable map from path to {@link RrpcInterfaceMethodMeta}. Never <jk>null</jk>.
336    */
337   public Map<String,RrpcInterfaceMethodMeta> getMethodsByPath() {
338      return methodMetasByPath;
339   }
340
341   /**
342    * Returns the method metadata for the given path.
343    *
344    * @param path The path. May be <jk>null</jk>.
345    * @return The matching method metadata, or <jk>null</jk> if no match was found.
346    */
347   public RrpcInterfaceMethodMeta getMethodMetaByPath(String path) {
348      return path == null ? null : methodMetasByPath.get(path);
349   }
350
351   /**
352    * Returns the base path from the {@link Remote#path() Remote} annotation.
353    *
354    * @return The base path. Never <jk>null</jk>, but may be empty.
355    */
356   public String getBasePath() {
357      return basePath;
358   }
359
360   /**
361    * Returns the interface-level base/host override, or an empty string if none.
362    *
363    * <p>
364    * Sourced from {@link Remote#baseUrl()} (resolved through {@link VarResolver#DEFAULT}).  Used as a substitute for
365    * the client root URL when neither an {@link org.apache.juneau.http.Url @Url} parameter nor a method-level
366    * {@code baseUrl} is in effect; preserves the interface base path + method path + templating.
367    *
368    * @return The base/host override. Never <jk>null</jk>, but may be empty.
369    */
370   public String getBaseUrl() {
371      return baseUrl;
372   }
373
374   /**
375    * Returns the interface-level default {@code contentType} media-type override, or an empty string.
376    *
377    * <p>
378    * Sourced from {@link Remote#contentType()} (resolved through {@link VarResolver#DEFAULT}).  Used as the default
379    * request-serializer/{@code Content-Type} selector for methods that declare no method-level
380    * {@link RrpcInterfaceMethodMeta#getContentType()}.
381    *
382    * @return The {@code contentType} media type. Never <jk>null</jk>, but may be empty.
383    */
384   public String getContentType() {
385      return contentType;
386   }
387
388   /**
389    * Returns the interface-level default {@code accept} media-type override, or an empty string.
390    *
391    * <p>
392    * Sourced from {@link Remote#accept()} (resolved through {@link VarResolver#DEFAULT}).  Used as the default
393    * {@code Accept} header / response-parser fallback for methods that declare no method-level
394    * {@link RrpcInterfaceMethodMeta#getAccept()}.
395    *
396    * @return The {@code accept} media type. Never <jk>null</jk>, but may be empty.
397    */
398   public String getAccept() {
399      return accept;
400   }
401
402   /**
403    * Returns the interface-level constant headers to set on every request.
404    *
405    * <p>
406    * Includes the headers declared via {@link Remote#headers()} plus the client-version header derived from
407    * {@link Remote#version()}/{@link Remote#versionHeader()} (if a version was specified).
408    *
409    * @return An unmodifiable list of name/value header entries. Never <jk>null</jk>, but may be empty.
410    */
411   public List<Map.Entry<String,String>> getHeaders() {
412      return headers;
413   }
414
415   /**
416    * Returns the interface-level constant query parameters to apply to every request.
417    *
418    * <p>
419    * Resolved from {@link Remote#queryData()} (the {@code "name=value"} form, with {@link VarResolver#DEFAULT}
420    * expansion).
421    *
422    * @return An unmodifiable list of name/value query entries. Never <jk>null</jk>, but may be empty.
423    */
424   public List<Map.Entry<String,String>> getQueryData() {
425      return queryData;
426   }
427
428   /**
429    * Returns the interface-level constant form-data parameters to apply to every request.
430    *
431    * <p>
432    * Resolved from {@link Remote#formData()} (the {@code "name=value"} form, with {@link VarResolver#DEFAULT}
433    * expansion).
434    *
435    * @return An unmodifiable list of name/value form-data entries. Never <jk>null</jk>, but may be empty.
436    */
437   public List<Map.Entry<String,String>> getFormData() {
438      return formData;
439   }
440
441   /**
442    * Returns the interface-level custom {@link HttpPartSerializer}, or <jk>null</jk> if none.
443    *
444    * <p>
445    * Sourced from {@code @HttpPartMarshalling(serializer=...)} declared on the interface.  Used as
446    * the lowest-precedence default for part serialization: a parameter-level or method-level
447    * {@code @HttpPartMarshalling} serializer takes precedence over it.
448    *
449    * @return The resolved part serializer, or <jk>null</jk> if none is configured.
450    */
451   public HttpPartSerializer getPartSerializer() {
452      return partSerializer;
453   }
454
455   /**
456    * Returns the interface-level interceptor classes, or an empty array if none.
457    *
458    * <p>
459    * Each class implements the next-gen client-side interceptor SPI and is resolved to an instance by the next-gen
460    * {@code RemoteClient} (the raw {@code Class<?>[]} type avoids a module dependency on {@code juneau-rest-client}).
461    *
462    * @return The interceptor classes. Never <jk>null</jk>, but may be empty.
463    */
464   public Class<?>[] getInterceptorClasses() {
465      return cp(interceptorClasses);
466   }
467
468   /**
469    * Returns the interface-level default per-call timeout as a duration string, or an empty string.
470    *
471    * @return The timeout duration string. Never <jk>null</jk>, but may be empty.
472    */
473   public String getTimeout() {
474      return timeout;
475   }
476
477   /**
478    * Returns the interface-level default max retry attempts, or {@code 0} if retries are disabled.
479    *
480    * @return The retry count.
481    */
482   public int getRetries() {
483      return retries;
484   }
485
486   /**
487    * Returns whether the interface opts non-idempotent verbs (POST/PATCH) into automatic retries.
488    *
489    * @return <jk>true</jk> if non-idempotent retries are opted in at the interface level.
490    */
491   public boolean isRetryNonIdempotent() {
492      return retryNonIdempotent;
493   }
494
495   /**
496    * Returns whether the interface throws a generic exception on an unmatched error response.
497    *
498    * @return <jk>true</jk> if {@code throwOnError} is set at the interface level.
499    */
500   public boolean isThrowOnError() {
501      return throwOnError;
502   }
503
504   /**
505    * Returns whether the interface opts its absolute {@code @Remote}/{@code @Url}/{@code baseUrl()} targets out of
506    * the default deny-private SSRF guardrail ({@link Remote#allowPrivateUrls()}).
507    *
508    * @return <jk>true</jk> if {@code allowPrivateUrls} is set at the interface level.
509    */
510   public boolean isAllowPrivateUrls() {
511      return allowPrivateUrls;
512   }
513
514   /**
515    * Returns the method metadata for all annotated methods.
516    *
517    * @return An unmodifiable map from {@link Method} to {@link RrpcInterfaceMethodMeta}. Never <jk>null</jk>.
518    */
519   public Map<Method,RrpcInterfaceMethodMeta> getMethodMetas() {
520      return methodMetas;
521   }
522
523   /**
524    * Returns the metadata for the given method, or {@code null} if the method has no remote annotation.
525    *
526    * @param method The method. Can be <jk>null</jk> (returns <jk>null</jk>).
527    * @return The metadata, or <jk>null</jk> if the method is <jk>null</jk> or has no remote annotation.
528    */
529   public RrpcInterfaceMethodMeta getMethodMeta(Method method) {
530      return methodMetas.get(method);
531   }
532
533   @SuppressWarnings({
534      "java:S3776" // Cognitive complexity inherent to per-verb interface-metadata parsing; refactoring would reduce readability without benefit.
535   })
536   private static Optional<RrpcInterfaceMethodMeta> buildMethodMeta(Method m) {
537      if (m.isAnnotationPresent(RemoteGet.class)) {
538         var a = m.getAnnotation(RemoteGet.class);
539         return o(simpleMeta(m, "GET", a.path(), a.value(), a.returns(), a.headers(), a.queryData(), a.formData(), a.baseUrl(),
540            new RrpcInterfaceMethodMeta.Policy(a.interceptors(), a.timeout(), a.retries(), a.retryNonIdempotent(), a.throwOnError()),
541            neg(a.accept(), a.contentType())));
542      }
543      if (m.isAnnotationPresent(RemotePost.class)) {
544         var a = m.getAnnotation(RemotePost.class);
545         return o(simpleMeta(m, "POST", a.path(), a.value(), a.returns(), a.headers(), a.queryData(), a.formData(), a.baseUrl(),
546            new RrpcInterfaceMethodMeta.Policy(a.interceptors(), a.timeout(), a.retries(), a.retryNonIdempotent(), a.throwOnError()),
547            neg(a.accept(), a.contentType())));
548      }
549      if (m.isAnnotationPresent(RemotePut.class)) {
550         var a = m.getAnnotation(RemotePut.class);
551         return o(simpleMeta(m, "PUT", a.path(), a.value(), a.returns(), a.headers(), a.queryData(), a.formData(), a.baseUrl(),
552            new RrpcInterfaceMethodMeta.Policy(a.interceptors(), a.timeout(), a.retries(), a.retryNonIdempotent(), a.throwOnError()),
553            neg(a.accept(), a.contentType())));
554      }
555      if (m.isAnnotationPresent(RemotePatch.class)) {
556         var a = m.getAnnotation(RemotePatch.class);
557         return o(simpleMeta(m, "PATCH", a.path(), a.value(), a.returns(), a.headers(), a.queryData(), a.formData(), a.baseUrl(),
558            new RrpcInterfaceMethodMeta.Policy(a.interceptors(), a.timeout(), a.retries(), a.retryNonIdempotent(), a.throwOnError()),
559            neg(a.accept(), a.contentType())));
560      }
561      if (m.isAnnotationPresent(RemoteDelete.class)) {
562         var a = m.getAnnotation(RemoteDelete.class);
563         return o(simpleMeta(m, "DELETE", a.path(), a.value(), a.returns(), a.headers(), a.queryData(), a.formData(), a.baseUrl(),
564            new RrpcInterfaceMethodMeta.Policy(a.interceptors(), a.timeout(), a.retries(), a.retryNonIdempotent(), a.throwOnError()),
565            neg(a.accept(), a.contentType())));
566      }
567      if (m.isAnnotationPresent(RemoteOp.class))
568         return o(buildRemoteOpMeta(m, m.getAnnotation(RemoteOp.class)));
569
570      return oe();
571   }
572
573   /**
574    * Builds a resolved {@link RrpcInterfaceMethodMeta.ContentNegotiation} carrier from the raw
575    * {@code accept}/{@code contentType} annotation members, expanding {@link VarResolver#DEFAULT} variables.
576    */
577   private static RrpcInterfaceMethodMeta.ContentNegotiation neg(String accept, String contentType) {
578      return new RrpcInterfaceMethodMeta.ContentNegotiation(VarResolver.DEFAULT.resolve(accept), VarResolver.DEFAULT.resolve(contentType));
579   }
580
581   @SuppressWarnings({
582      "java:S107" // The 11 parameters mirror the verb-annotation surface (verb/path/value/return + the 3 constant-part arrays + the baseUrl override + the cross-cutting policy carrier + the content-negotiation carrier) threaded into the method-meta; holder types would add indirection without improving this internal builder.
583   })
584   private static RrpcInterfaceMethodMeta simpleMeta(Method m, String httpMethod, String pathAttr, String valueAttr, RemoteReturn returnType, String[] headers, String[] queryData, String[] formData, String baseUrl, RrpcInterfaceMethodMeta.Policy policy, RrpcInterfaceMethodMeta.ContentNegotiation neg) {
585      var path = pathAttr.isEmpty() ? valueAttr : pathAttr;
586      return new RrpcInterfaceMethodMeta(m, httpMethod, path, returnType,
587         parseConstantParts(headers, ':'),
588         parseConstantParts(queryData, '='),
589         parseConstantParts(formData, '='),
590         VarResolver.DEFAULT.resolve(baseUrl),
591         policy,
592         neg);
593   }
594
595   private static RrpcInterfaceMethodMeta buildRemoteOpMeta(Method m, RemoteOp a) {
596      var method = a.method().trim();
597      var path = a.path().trim();
598      var returnType = a.returns();
599      var v = a.value().trim();
600      if (!v.isEmpty()) {
601         var i = v.indexOf(' ');
602         if (i == -1) {
603            method = ucr(v);
604         } else {
605            method = ucr(v.substring(0, i).trim());
606            path = v.substring(i).trim();
607         }
608      }
609      if (path.isEmpty())
610         path = HttpUtils.detectHttpPath(m, isEmpty(method) ? null : method);
611      if (method.isEmpty())
612         method = HttpUtils.detectHttpMethod(m, true, "GET");
613      if (!isOneOf(method, "DELETE", "GET", "POST", "PUT", "OPTIONS", "HEAD", "CONNECT", "TRACE", "PATCH"))
614         throw iaex("Invalid @RemoteOp method '%s' on %s.%s", method, m.getDeclaringClass().getName(), m.getName());
615      return new RrpcInterfaceMethodMeta(m, method, trimSlashes(path), returnType,
616         parseConstantParts(a.headers(), ':'),
617         parseConstantParts(a.queryData(), '='),
618         parseConstantParts(a.formData(), '='),
619         VarResolver.DEFAULT.resolve(a.baseUrl()),
620         new RrpcInterfaceMethodMeta.Policy(a.interceptors(), a.timeout(), a.retries(), a.retryNonIdempotent(), a.throwOnError()),
621         neg(a.accept(), a.contentType()));
622   }
623
624   @Override /* Object */
625   public String toString() {
626      return "@Remote " + iface.getSimpleName() + " (basePath=" + basePath + ", methods=" + methodMetas.size() + ")";
627   }
628}