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.server;
018
019import static org.apache.juneau.commons.function.Suppliers.*;
020import static org.apache.juneau.commons.httppart.HttpPartType.*;
021import static org.apache.juneau.commons.reflect.AnnotationTraversal.*;
022import static org.apache.juneau.commons.utils.CollectionUtils.*;
023import static org.apache.juneau.commons.utils.Shorts.*;
024import static org.apache.juneau.commons.utils.StringUtils.*;
025import static org.apache.juneau.commons.utils.StringUtils.isNotEmpty;
026import static org.apache.juneau.commons.utils.ThrowableUtils.*;
027import static org.apache.juneau.rest.server.util.RestUtils.*;
028
029import java.lang.annotation.*;
030import java.lang.reflect.Method;
031import java.nio.charset.*;
032import java.util.*;
033import java.util.concurrent.*;
034import java.util.function.*;
035import java.util.logging.*;
036import java.util.stream.*;
037
038import org.apache.juneau.commons.*;
039import org.apache.juneau.commons.collections.*;
040import org.apache.juneau.commons.function.*;
041import org.apache.juneau.commons.http.*;
042import org.apache.juneau.commons.inject.*;
043import org.apache.juneau.commons.lang.*;
044import org.apache.juneau.commons.logging.*;
045import org.apache.juneau.commons.reflect.*;
046import org.apache.juneau.commons.svl.*;
047import org.apache.juneau.commons.utils.*;
048import org.apache.juneau.http.*;
049import org.apache.juneau.http.header.*;
050import org.apache.juneau.http.part.*;
051import org.apache.juneau.http.response.*;
052import org.apache.juneau.httppart.bean.*;
053import org.apache.juneau.marshall.*;
054import org.apache.juneau.marshall.encoders.*;
055import org.apache.juneau.marshall.httppart.*;
056import org.apache.juneau.marshall.jsonschema.*;
057import org.apache.juneau.marshall.parser.*;
058import org.apache.juneau.marshall.serializer.*;
059import org.apache.juneau.rest.common.utils.*;
060import org.apache.juneau.rest.server.converter.*;
061import org.apache.juneau.rest.server.guard.*;
062import org.apache.juneau.rest.server.httppart.*;
063import org.apache.juneau.rest.server.matcher.*;
064import org.apache.juneau.rest.server.util.*;
065
066import jakarta.servlet.*;
067import jakarta.servlet.http.*;
068
069/**
070 * Represents a single Java servlet/resource method annotated with {@link RestOp @RestOp}.
071 *
072 * <h5 class='section'>Notes:</h5><ul>
073 *    <li class='note'>This class is thread safe and reusable.
074 * </ul>
075 *
076 * <h5 class='section'>See Also:</h5><ul>
077 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestOpContext">RestOpContext</a>
078 * </ul>
079 */
080@SuppressWarnings({
081   "java:S112", // throws Exception intentional - callback/lifecycle methods throughout this class
082   "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants
083   "java:S1200", // High coupling is intentional in this central operation context aggregator.
084   "java:S3776", // Branching is inherent to the RestOpContext constructor and compareTo(), which resolve many annotation-driven settings and ordering keys.
085   "java:S6539", // RestOpContext intentionally centralizes op wiring and annotation resolution.
086   "rawtypes", // Raw types necessary for generic type handling.
087   "resource", // op-level BasicBeanStores are short-lived scratch stores; the long-lived opBeanStore field is owned and closed by the parent RestContext via its bean-store hierarchy.
088   "unchecked" // Cast is safe: type parameter is verified by caller context.
089})
090public class RestOpContext extends Context implements Comparable<RestOpContext> {
091
092   // Argument name constants for req*
093
094   private static final AnnotationProvider AP = AnnotationProvider.INSTANCE;
095
096   /**
097    * Env-driven default charset for this operation, populated by {@link ClassInfo#inject(Object, BeanStore)}
098    * before the {@link #defaultCharset} memoizer fires; resolved from {@code RestContext.defaultCharset} or
099    * {@code "UTF-8"}.
100    */
101   @Value("${RestContext.defaultCharset:UTF-8}")
102   private String defaultCharsetName;
103
104   /**
105    * Env-driven default max-input string for this operation, populated by {@link ClassInfo#inject(Object, BeanStore)}
106    * before the {@link #maxInput} memoizer fires; resolved from {@code RestContext.maxInput} or
107    * {@code "1000000"} &mdash; matching the {@code @Rest}-level fallback in
108    * {@link org.apache.juneau.rest.server.config.DefaultConfig DefaultConfig} so an operation that falls all the
109    * way through to this last-resort default doesn't end up with a substantially larger effective ceiling than
110    * the class-level default.
111    */
112   @Value("${RestContext.maxInput:1000000}")
113   private String defaultMaxInputString;
114
115   /**
116    * Internal construction-time state holder.
117    */
118   static class Builder extends Context.Builder {
119
120      private final Method restMethod;
121      private final RestContext restContext;
122      private final Supplier<Object> restResourceSupplier;
123
124      Builder(Method method, RestContext context) {
125         this(method, context, null);
126      }
127
128      Builder(Method method, RestContext context, Supplier<Object> resourceSupplier) {
129
130         this.restContext = context;
131         this.restMethod = method;
132         this.restResourceSupplier = resourceSupplier == null ? context::getResource : resourceSupplier;
133
134         var ap = context.getMarshallingContext().getAnnotationProvider();
135         var mi = MethodInfo.of(context.getResourceClass(), method);
136         var resourceClass = ClassInfo.of(context.getResourceClass());
137
138         try {
139            var vr = context.getVarResolver();
140            var vrs = vr.createSession();
141
142            // Parent-to-child merge: class-level annotations (parent class chain) applied first, then method-level
143            // (matching-methods chain, return type, package). Method annotations thus override class annotations
144            // for the same property. LinkedHashSet deduplicates while preserving order.
145            //
146            // For a mixin op (the op's context is a mixin sub-context), the host's class-level @ContextApply
147            // config (e.g. @HtmlDocConfig, @SerializerConfig) is prepended ahead of the mixin class's own class
148            // annotations, so the effective precedence is method > mixin-class > host-class. This is the
149            // op-level counterpart of the context-level inheritance walk and is gated by @Rest(noInherit) on
150            // the mixin. For non-mixin contexts the host stream is empty (no behavior change).
151            var hostClassAnnotations = context.getInheritedHostClassAnnotations(ap);
152            var declaringClassAnnotations = rstream(ap.find(resourceClass, SELF, PARENTS));
153            var methodAnnotations = rstream(ap.find(mi, SELF, MATCHING_METHODS, RETURN_TYPE, PACKAGE));
154            var allAnnotationsSet = new LinkedHashSet<AnnotationInfo<?>>();
155            hostClassAnnotations.forEach(allAnnotationsSet::add);
156            declaringClassAnnotations.forEach(allAnnotationsSet::add);
157            methodAnnotations.forEach(allAnnotationsSet::add);
158            var work = AnnotationWorkList.of(vrs, allAnnotationsSet.stream().filter(CONTEXT_APPLY_FILTER));
159
160            apply(work);
161
162         } catch (Exception e) {
163            throw new InternalServerError(e);
164         }
165      }
166
167      @Override /* Overridden from Context.Builder<?> */
168      public Builder copy() {
169         throw new NoSuchMethodError("Not implemented.");
170      }
171   }
172
173   private static HttpPartSerializer createPartSerializer(Class<? extends HttpPartSerializer> c, HttpPartSerializer defaultSerializer) {
174      return c == null
175         ? defaultSerializer
176         : BeanInstantiator.of(HttpPartSerializer.class).type(c).fallback(() -> defaultSerializer).run();
177   }
178
179   private final WritableBeanStore opBeanStore;
180   protected final Map<Class<?>,ResponseBeanMeta> responseBeanMetas = new ConcurrentHashMap<>();
181   protected final Map<Class<?>,ResponsePartMeta> headerPartMetas = new ConcurrentHashMap<>();
182   protected final Method method;
183   protected final MethodInfo mi;
184   protected final RestContext context;
185   private final Supplier<Object> resourceSupplier;
186
187   // The annotation work-list produced during construction.
188   private final AnnotationWorkList appliedAnnotations;
189
190   private RestContext restContext() { return context; }
191
192   /**
193    * Returns the {@link RestContext} that owns this operation context.
194    *
195    * <p>
196    * For operations declared directly on the host resource class, returns the host context. For operations
197    * registered through a {@code @Rest(mixins=...)} mixin, returns the per-mixin sub-context (parent-linked
198    * to the host's context via {@link RestContext#getParentContext()}). Callers can use this to identify
199    * whether the operation was contributed by a mixin (via {@link RestContext#isMixinContext()}).
200    *
201    * @return The owning {@link RestContext} for this operation.
202    * @since 10.0.0
203    */
204   public RestContext getContext() { return context; }
205
206   private Method method() { return method; }
207   private MethodInfo methodInfo() { return mi; }
208   private AnnotationWorkList appliedAnnotations() { return appliedAnnotations; }
209   private BeanStore beanStore() { return context.getBeanStore(); }
210   private WritableBeanStore opBeanStore() { return opBeanStore; }
211   private Object resource() { return resourceSupplier.get(); }
212   private VarResolver varResolver() { return context.getVarResolver(); }
213
214   //-----------------------------------------------------------------------------------------------------------------
215   // Memoized fields
216   //-----------------------------------------------------------------------------------------------------------------
217
218   /** Effective allowed parser session-option keys for this operation. */
219   private final Memoizer<SortedSet<String>> allowedParserOptions = memoizer(() -> {
220      var l = new ArrayList<String>();
221      var p = "allowedParserOptions";
222      if (isInherited(p))
223         l.addAll(restContext().getAllowedParserOptions());
224      getRestOpAnnotations().stream()
225         .flatMap(ai -> resolveCdl(ai.getStringArray(p).orElse(EMPTY_STRING_ARRAY)))
226         .forEach(l::add);
227      return u(treeSetCi(removeNegations(l)));
228   });
229
230   /** Effective allowed serializer session-option keys for this operation. */
231   private final Memoizer<SortedSet<String>> allowedSerializerOptions = memoizer(() -> {
232      var l = new ArrayList<String>();
233      var p = "allowedSerializerOptions";
234      if (isInherited(p))
235         l.addAll(restContext().getAllowedSerializerOptions());
236      getRestOpAnnotations().stream()
237         .flatMap(ai -> resolveCdl(ai.getStringArray(p).orElse(EMPTY_STRING_ARRAY)))
238         .forEach(l::add);
239      return u(treeSetCi(removeNegations(l)));
240   });
241
242   /** The {@link MarshallingContext} for this operation (op-level annotations applied on top of the parent context). */
243   private final Memoizer<MarshallingContext> marshallingContext = memoizer(() -> {
244      var aa = appliedAnnotations();
245      var parent = restContext().getBeanContextBuilder();
246      if (!parent.canApply(aa))
247         return restContext().getMarshallingContext();
248      Holder<MarshallingContext.Builder> v = Holder.of(parent.copy());
249      v.get().apply(aa);
250      var bs = new BasicBeanStore(beanStore())
251         .addBean(Method.class, method())
252         .addBean(MarshallingContext.Builder.class, v.get());
253      bs.createBeanFromMethod(MarshallingContext.class, resource(), this::matchesInjectScope)
254         .ifPresent(x -> v.get().impl(x));
255      return v.get().build();
256   });
257
258   /**
259    * The per-operation logger for debug capture.
260    *
261    * <p>
262    * A hierarchical child of the <b>host</b> resource's {@linkplain RestContext#getLogger() resolved logger}
263    * ({@code <hostLoggerName>.<methodName>}) so an operator can raise the debug level for a single operation
264    * without affecting its siblings (JUL parent-level inheritance). Deriving from the resolved logger &mdash;
265    * rather than the raw resource class name &mdash; means a bean-store {@code RichLogger} override on the host
266    * (see {@link RestContext#getLogger()}) is honored: the op logger is a true JUL child of whatever logger the
267    * host actually resolves to, not a same-named-but-unrelated sibling.
268    *
269    * <p>
270    * For an operation contributed by a composed {@linkplain Rest#mixins() mixin}, the host is the <b>host /
271    * top-level resource</b> &mdash; not the mixin class &mdash; so raising the host resource's JUL level cascades
272    * to its mixin-served operations, and the same mixin composed into different hosts resolves to distinct,
273    * host-isolated loggers.  Non-mixin operations (including child resources, which are their own resources)
274    * resolve to their own resource's logger as before.
275    */
276   private final Memoizer<RichLogger> logger = memoizer(() ->
277      RichLogger.getLogger(hostRestContext().getLogger().getName() + "." + getJavaMethod().getName()));
278
279   /**
280    * Returns the host / top-level resource context for logger naming.
281    *
282    * <p>
283    * When this operation's context originates from a {@linkplain RestContext#isMixinContext() mixin sub-context},
284    * walks up the {@linkplain RestContext#getParentContext() parent-context} linkage past any composed mixin
285    * sub-contexts to the host resource that composed the mixin.  Non-mixin contexts (including child resources)
286    * return their own context unchanged.
287    *
288    * @return The host resource context.
289    */
290   private RestContext hostRestContext() {
291      var rc = restContext();
292      while (rc.isMixinContext() && rc.getParentContext() != null)
293         rc = rc.getParentContext();
294      return rc;
295   }
296
297   /**
298    * The response-converter array for this operation.
299    *
300    * <p>
301    * Walks the {@code @Rest(converters)} class chain (parent-to-child) followed by the
302    * {@code @RestOp(converters)} method chain (parent-to-child). Op-level
303    * {@code noInherit={"converters"}} cuts off the class-chain contribution. An
304    * {@code @Bean RestConverterList} bean (either as a name-anonymous bean in the bean store
305    * or as a {@code @Bean} method whose {@code methodScope} matches this operation's method
306    * name) REPLACES the entire annotation-derived list.
307    */
308   private final Memoizer<RestConverter[]> converters = memoizer(() -> {
309      var bs = beanStore();
310      var b = RestConverterList.create(bs);
311      if (isInherited("converters"))
312         restContext().getRestAnnotationsForProperty("converters")
313            .forEach(ai -> b.append(ai.inner().converters()));
314      getRestOpAnnotationsForProperty("converters")
315         .forEach(ai -> ai.getClassArray("converters", RestConverter.class).ifPresent(classes -> {
316            for (var c : classes)
317               b.append(classArray(c));
318         }));
319      var override = bs.createBeanFromMethod(RestConverterList.class, resource(), this::matchesInjectScope).orElse(null);
320      if (override == null)
321         override = bs.getBean(RestConverterList.class).orElse(null);
322      return (nn(override) ? override : b.build()).asArray();
323   });
324
325   /** The effective default {@link Charset} for this operation, resolved from op annotations, context, or env. */
326   private final Memoizer<Charset> defaultCharset = memoizer(() -> {
327      var v = findOpString("defaultCharset");
328      if (v.isPresent())
329         return Charset.forName(v.get());
330      if (isInherited("defaultCharset")) {
331         var rv = restContext().mergeReplacedStringAttribute("defaultCharset", null);
332         if (ine(rv))
333            return Charset.forName(rv);
334      }
335      return Charset.forName(defaultCharsetName);
336   });
337
338   /**
339    * The default request attribute map for this operation.
340    *
341    * <p>
342    * Starts with the class-level value (which already incorporates
343    * {@code @Rest(defaultRequestAttributes)}), then walks the {@code @RestOp}/verb chain
344    * (parent-to-child) and adds each entry — {@link NamedAttributeMap#add} uses put-semantics so
345    * child entries override parent entries by name. An
346    * {@code @Bean(name="defaultRequestAttributes") NamedAttributeMap} bean (matching this
347    * operation's method scope) REPLACES the entire result.
348    */
349   private final Memoizer<NamedAttributeMap> defaultRequestAttributes = memoizer(() -> {
350      var v = Holder.of(restContext().getDefaultRequestAttributes().copy());
351      getRestOpAnnotationsForProperty("defaultRequestAttributes").forEach(ai -> {
352         for (var s : ai.getStringArray("defaultRequestAttributes").orElse(EMPTY_STRING_ARRAY))
353            v.get().add(BasicNamedAttribute.ofPair(s));
354      });
355      beanStore().createBeanFromMethod(NamedAttributeMap.class, resource(), x -> matchesInjectScope(x, "defaultRequestAttributes"))
356         .ifPresent(v::set);
357      return v.get();
358   });
359
360   /**
361    * The default request form-data parts for this operation.
362    *
363    * <p>
364    * Walks the {@code @RestOp}/verb chain (parent-to-child); each {@code defaultRequestFormData}
365    * entry is applied with {@link PartList#setDefault} (first-in-chain wins per name).
366    * Method-parameter {@link FormData @FormData} annotations with a
367    * {@link Schema#default_()}/{@link Schema#df()} default are folded in last (also
368    * {@code setDefault} = first wins). An {@code @Bean(name="defaultRequestFormData") PartList}
369    * bean (matching this operation's method scope) REPLACES the entire result.
370    */
371   private final Memoizer<HttpPartList> defaultRequestFormData = memoizer(() -> {
372      var v = Holder.of(HttpPartList.create());
373      getRestOpAnnotationsForProperty("defaultRequestFormData").forEach(ai -> {
374         for (var s : ai.getStringArray("defaultRequestFormData").orElse(EMPTY_STRING_ARRAY))
375            v.get().setDefault(HttpStringPart.ofPair(s));
376      });
377      processParameterDefaults((paramAnn, def) -> {
378         if (paramAnn instanceof FormData paramAnn2) {
379            try {
380               v.get().setDefault(HttpStringPart.of(firstNonEmpty(paramAnn2.name(), paramAnn2.value()), toPartValue(parseIfJson(def))));
381            } catch (ParseException e) {
382               throw new ConfigException(e, "Malformed @FormData annotation");
383            }
384         }
385      });
386      beanStore().createBeanFromMethod(HttpPartList.class, resource(), x -> matchesInjectScope(x, "defaultRequestFormData"))
387         .ifPresent(v::set);
388      return v.get();
389   });
390
391   /**
392    * The default request headers for this operation.
393    *
394    * <p>
395    * Starts with the class-level value (which already incorporates
396    * {@code @Rest(defaultRequestHeaders|defaultAccept|defaultContentType)}), then walks the
397    * {@code @RestOp}/verb chain (parent-to-child); each annotation's {@code defaultRequestHeaders}
398    * entries plus its {@code defaultAccept} / {@code defaultContentType} (folded into
399    * {@code Accept} / {@code Content-Type} headers) are applied with {@link HeaderList#setDefault}
400    * (first-in-chain wins per name — class-level beats op-level beats later op-level entries).
401    *
402    * <p>
403    * Method-parameter {@link Header @Header} annotations with a
404    * {@link Schema#default_()}/{@link Schema#df()} default are folded in last via
405    * {@link HeaderList#set} (overrides any prior entry with the same name). An
406    * {@code @Bean(name="defaultRequestHeaders") HeaderList} bean (matching this operation's
407    * method scope) REPLACES the entire result.
408    */
409   private final Memoizer<HttpHeaderList> defaultRequestHeaders = memoizer(() -> {
410      var v = Holder.of(restContext().getDefaultRequestHeaders().copy());
411      getRestOpAnnotationsForProperty("defaultRequestHeaders").forEach(ai -> {
412         for (var s : ai.getStringArray("defaultRequestHeaders").orElse(EMPTY_STRING_ARRAY))
413            v.get().setDefault(HttpStringHeader.ofPair(s));
414         ai.getString("defaultAccept").filter(s -> !s.isEmpty()).ifPresent(s -> v.get().setDefault(Accept.of(s)));
415         ai.getString("defaultContentType").filter(s -> !s.isEmpty()).ifPresent(s -> v.get().setDefault(ContentType.of(s)));
416      });
417      processParameterDefaults((paramAnn, def) -> {
418         if (paramAnn instanceof Header paramAnn2) {
419            try {
420               v.get().set(HttpStringHeader.of(firstNonEmpty(paramAnn2.name(), paramAnn2.value()), toPartValue(parseIfJson(def))));
421            } catch (ParseException e) {
422               throw new ConfigException(e, "Malformed @Header annotation");
423            }
424         }
425      });
426      beanStore().createBeanFromMethod(HttpHeaderList.class, resource(), x -> matchesInjectScope(x, "defaultRequestHeaders"))
427         .ifPresent(v::set);
428      return v.get();
429   });
430
431   /**
432    * The default request query-data parts for this operation.
433    *
434    * <p>
435    * Walks the {@code @RestOp}/verb chain (parent-to-child); each {@code defaultRequestQueryData}
436    * entry is applied with {@link PartList#setDefault} (first-in-chain wins per name).
437    * Method-parameter {@link Query @Query} annotations with a
438    * {@link Schema#default_()}/{@link Schema#df()} default are folded in last (also
439    * {@code setDefault} = first wins). An {@code @Bean(name="defaultRequestQueryData") PartList}
440    * bean (matching this operation's method scope) REPLACES the entire result.
441    */
442   private final Memoizer<HttpPartList> defaultRequestQueryData = memoizer(() -> {
443      var v = Holder.of(HttpPartList.create());
444      getRestOpAnnotationsForProperty("defaultRequestQueryData").forEach(ai -> {
445         for (var s : ai.getStringArray("defaultRequestQueryData").orElse(EMPTY_STRING_ARRAY))
446            v.get().setDefault(HttpStringPart.ofPair(s));
447      });
448      processParameterDefaults((paramAnn, def) -> {
449         if (paramAnn instanceof Query paramAnn2) {
450            try {
451               v.get().setDefault(HttpStringPart.of(firstNonEmpty(paramAnn2.name(), paramAnn2.value()), toPartValue(parseIfJson(def))));
452            } catch (ParseException e) {
453               throw new ConfigException(e, "Malformed @Query annotation");
454            }
455         }
456      });
457      beanStore().createBeanFromMethod(HttpPartList.class, resource(), x -> matchesInjectScope(x, "defaultRequestQueryData"))
458         .ifPresent(v::set);
459      return v.get();
460   });
461
462   /**
463    * The default response headers for this operation.
464    *
465    * <p>
466    * Starts with the class-level value (which already incorporates
467    * {@code @Rest(defaultResponseHeaders)}), then walks the {@code @RestOp}/verb chain
468    * (parent-to-child); each annotation's {@code defaultResponseHeaders} entries are applied with
469    * {@link HeaderList#setDefault} (first-in-chain wins per name). An
470    * {@code @Bean(name="defaultResponseHeaders") HeaderList} bean (matching this operation's
471    * method scope) REPLACES the entire result.
472    */
473   private final Memoizer<HttpHeaderList> defaultResponseHeaders = memoizer(() -> {
474      var v = Holder.of(restContext().getDefaultResponseHeaders().copy());
475      getRestOpAnnotationsForProperty("defaultResponseHeaders").forEach(ai -> {
476         for (var s : ai.getStringArray("defaultResponseHeaders").orElse(EMPTY_STRING_ARRAY))
477            v.get().setDefault(HttpStringHeader.ofPair(s));
478      });
479      beanStore().createBeanFromMethod(HttpHeaderList.class, resource(), x -> matchesInjectScope(x, "defaultResponseHeaders"))
480         .ifPresent(v::set);
481      return v.get();
482   });
483
484   /**
485    * The encoder group for this operation.
486    *
487    * <p>
488    * Walks the {@code @RestOp(encoders)} chain (parent-to-child); each non-empty {@code encoders()}
489    * array REPLACES the inherited set (with {@link Inherit} as a sentinel that re-injects the prior
490    * set's entries at the specified position — matches the legacy {@code EncoderSet.Builder.set(...)}
491    * semantics). Falls through to the class-level {@link RestContext#getEncoders()} when no op
492    * annotation declares encoders. An {@code @Bean EncoderSet} bean (matching this operation's
493    * method scope) REPLACES the result entirely.
494    */
495   private final Memoizer<EncoderSet> encoders = memoizer(() -> {
496      var bs = beanStore();
497      var b = restContext().getEncodersBuilder().copy();
498      getRestOpAnnotationsForProperty("encoders").forEach(ai -> {
499         var c = ai.getClassArray("encoders", Encoder.class).orElse(null);
500         if (nn(c) && c.length > 0)
501            b.set(c);
502      });
503      var v = Holder.of(b.build());
504      bs.createBeanFromMethod(EncoderSet.class, resource(), this::matchesInjectScope)
505         .ifPresent(v::set);
506      return v.get();
507   });
508
509   /**
510    * The request-guard array for this operation. <b>Cross-bucket memoizer</b> — folds three
511    * annotation attributes ({@code guards}, {@code roleGuard}, {@code rolesDeclared}) into a
512    * single effective {@link RestGuardList}.
513    *
514    * <p>
515    * Walk order: class {@code @Rest} chain (parent-to-child, gated by op-level
516    * {@code noInherit={"guards"}}), then op {@code @RestOp} chain (parent-to-child). Concretely:
517    * <ul>
518    *    <li>Each annotation contributes its {@code guards()} classes (appended in chain order).
519    *    <li>Each annotation contributes its {@code rolesDeclared()} CDL into a single role-name set.
520    *    <li>Each annotation contributes its {@code roleGuard()} string (when non-blank). Each
521    *       non-blank {@code roleGuard} becomes a {@link RoleBasedRestGuard} appended to the list,
522    *       using the accumulated {@code rolesDeclared} set as its allow-list.
523    * </ul>
524    *
525    * <p>
526    * An {@code @Bean RestGuardList} bean (via the bean store or an {@code @Bean} method
527    * with matching {@code methodScope}) REPLACES the entire annotation-derived list.
528    */
529   private final Memoizer<RestGuard[]> guards = memoizer(() -> {
530      var bs = beanStore();
531      var b = RestGuardList.create(bs);
532      var rolesDeclaredSet = new LinkedHashSet<String>();
533      var roleGuardStrs = new ArrayList<String>();
534
535      Consumer<AnnotationInfo<?>> walk = ai -> {
536         ai.getClassArray("guards", RestGuard.class).ifPresent(classes -> {
537            for (var c : classes)
538               b.append(classArray(c));
539         });
540         // rolesDeclared is a single CDL string (not an array) on every annotation in the chain.
541         ai.getString("rolesDeclared").filter(StringUtils::isNotBlank)
542            .ifPresent(s -> resolveCdl(s).forEach(rolesDeclaredSet::add));
543         ai.getString("roleGuard").filter(StringUtils::isNotBlank).ifPresent(roleGuardStrs::add);
544      };
545
546      if (isInherited("guards"))
547         restContext().getRestAnnotationsForProperty("guards").forEach(walk::accept);
548      getRestOpAnnotationsForProperty("guards").forEach(walk);
549
550      // When no @Rest/@RestOp(rolesDeclared) is set, pass null so RoleBasedRestGuard
551      // infers role names from the expression itself (legacy semantics — an empty
552      // declared-role set would silently never match).
553      var declaredRoles = rolesDeclaredSet.isEmpty() ? null : rolesDeclaredSet;
554      for (var rg : roleGuardStrs) {
555         try {
556            b.append(new RoleBasedRestGuard(declaredRoles, rg));
557         } catch (java.text.ParseException e) {
558            throw toRex(e);
559         }
560      }
561
562      var override = bs.createBeanFromMethod(RestGuardList.class, resource(), this::matchesInjectScope).orElse(null);
563      if (override == null)
564         override = bs.getBean(RestGuardList.class).orElse(null);
565      return (nn(override) ? override : b.build()).asArray();
566   });
567
568   /** The depth of the declaring class in the inheritance chain. */
569   private final Memoizer<Integer> hierarchyDepth = memoizer(() -> {
570      int hierarchyDepthTemp = 0;
571      var sc = method().getDeclaringClass().getSuperclass();
572      while (nn(sc)) {
573         hierarchyDepthTemp++;
574         sc = sc.getSuperclass();
575      }
576      return hierarchyDepthTemp;
577   });
578
579   /**
580    * The {@code @RestOp}-group annotation that supplies this operation's HTTP method.
581    *
582    * <p>
583    * Walks {@code @RestOp}-group annotations child-to-parent; verb-specific annotations
584    * ({@link RestGet}, etc.) imply their fixed verb. Empty when no annotation declares a verb, in which case the
585    * verb is inferred from the Java method name.
586    */
587   private final Memoizer<Optional<AnnotationInfo<?>>> httpMethodSource = memoizer(() -> {
588      var vr = varResolver();
589      for (var ai : getRestOpAnnotations())
590         if (ine(httpMethodFromAnnotation(ai.inner(), vr)))
591            return o(ai);
592      return oe();
593   });
594
595   /**
596    * The HTTP method string for this operation (e.g. {@code "GET"}, {@code "*"}).
597    *
598    * <p>
599    * Taken from {@link #httpMethodSource}. Falls back to {@link HttpUtils#detectHttpMethod} when no annotation
600    * declares a verb.
601    */
602   private final Memoizer<String> httpMethod = memoizer(() ->
603      normalizeHttpMethod(httpMethodSource.get()
604         .map(ai -> httpMethodFromAnnotation(ai.inner(), varResolver()))
605         .orElseGet(() -> HttpUtils.detectHttpMethod(method(), true, "GET"))));
606
607   /**
608    * Whether this operation is declared {@link RestOp#read() read=true}.
609    *
610    * <p>
611    * Read from the annotation that supplies the HTTP method, so the verb and its read declaration travel together.
612    * When the verb is inferred, it comes from the nearest {@link RestOp @RestOp}. A {@code read=true} on an
613    * overridden annotation that names its own verb is replaced along with that verb. One on another
614    * {@link RestOp @RestOp} that names no verb would be silently ignored, so it fails the operation.
615    */
616   private final Memoizer<Boolean> read = memoizer(() -> {
617      var vr = varResolver();
618      var annotations = getRestOpAnnotations();
619      var source = httpMethodSource.get()
620         .orElseGet(() -> annotations.stream().filter(x -> x.inner() instanceof RestOp).findFirst().orElse(null));
621      for (var ai : annotations) {
622         // An annotation that names its own verb but was overridden by a nearer one is replaced along with its read.
623         // One that names no verb would have its read silently ignored, so that is refused.
624         if (ai != source && ai.inner() instanceof RestOp a2 && a2.read() && ! ine(httpMethodFromAnnotation(a2, vr)))
625            throw new InternalServerError(
626               "Operation '" + method().getDeclaringClass().getSimpleName() + "." + method().getName()
627               + "' declares @RestOp(read=true) on an annotation that does not supply its method '" + getHttpMethod() + "'."
628               + " The read declaration is taken only from the annotation that names the method."
629               + " Move read=true to that annotation, or remove it.");
630      }
631      return nn(source) && source.inner() instanceof RestOp a2 && a2.read();
632   });
633
634   /** The JSON-Schema generator for this operation (op-level annotations applied on top of the parent). */
635   private final Memoizer<JsonSchemaGenerator> jsonSchemaGenerator = memoizer(() -> {
636      var aa = appliedAnnotations();
637      var parent = restContext().getJsonSchemaGeneratorBuilder();
638      if (!parent.canApply(aa))
639         return restContext().getJsonSchemaGenerator();
640      Holder<JsonSchemaGenerator.Builder> v = Holder.of(parent.copy());
641      v.get().apply(aa);
642      var bs = new BasicBeanStore(beanStore())
643         .addBean(Method.class, method())
644         .addBean(JsonSchemaGenerator.Builder.class, v.get());
645      bs.createBeanFromMethod(JsonSchemaGenerator.class, resource(), this::matchesInjectScope)
646         .ifPresent(x -> v.get().impl(x));
647      return v.get().build();
648   });
649
650   /**
651    * The request-matcher list for this operation. <b>Cross-bucket memoizer</b> — folds
652    * {@code matchers} and {@code clientVersion} (both op-level only) into a single effective
653    * {@link RestMatcherList}.
654    *
655    * <p>
656    * Walks the {@code @RestOp}/verb annotation chain (parent-to-child, gated by op-level
657    * {@code noInherit={"matchers"}}). Each annotation contributes its {@code matchers()} classes
658    * (appended in chain order). The final non-blank {@code clientVersion()} (most-derived wins)
659    * appends a single {@link ClientVersionMatcher} keyed off the resource's client-version header.
660    * An {@code @Bean RestMatcherList} bean (via the bean store or an {@code @Bean}
661    * method with matching {@code methodScope}) REPLACES the entire annotation-derived list.
662    */
663   private final Memoizer<RestMatcherList> matchersList = memoizer(() -> {
664      var bs = beanStore();
665      var b = RestMatcherList.create(bs);
666      var clientVersion = new String[]{null};
667
668      getRestOpAnnotationsForProperty("matchers").forEach(ai -> {
669         ai.getClassArray("matchers", RestMatcher.class).ifPresent(classes -> {
670            for (var c : classes)
671               b.append(classArray(c));
672         });
673         ai.getString("clientVersion").filter(StringUtils::isNotBlank).ifPresent(s -> clientVersion[0] = s);
674      });
675
676      if (nn(clientVersion[0]))
677         b.append(new ClientVersionMatcher(restContext().getClientVersionHeader(), MethodInfo.of(method())));
678
679      var override = bs.createBeanFromMethod(RestMatcherList.class, resource(), this::matchesInjectScope).orElse(null);
680      if (override == null)
681         override = bs.getBean(RestMatcherList.class).orElse(null);
682      return nn(override) ? override : b.build();
683   });
684
685   /** The invoker for the operation method itself. */
686   private final Memoizer<RestOpInvoker> methodInvoker = memoizer(() ->
687      new RestOpInvoker(method(), restContext().findRestOperationArgs(method(), opBeanStore()), restContext().getMethodExecStats(method()), this::resource)
688   );
689
690   /** The effective max-input byte limit for this operation. */
691   private final Memoizer<Long> maxInput = memoizer(() -> {
692      var v = findOpString("maxInput");
693      if (v.isPresent())
694         return parseLongWithSuffix(v.get());
695      if (isInherited("maxInput")) {
696         var rv = restContext().mergeReplacedStringAttribute("maxInput", null);
697         if (ine(rv))
698            return parseLongWithSuffix(rv);
699      }
700      return parseLongWithSuffix(defaultMaxInputString);
701   });
702
703   /**
704    * Whether this operation emits RFC 7807 {@code application/problem+json} responses.
705    *
706    * <p>
707    * Tri-state semantics on the {@code @RestOp}-group annotations:
708    * <ul>
709    *    <li>{@code "true"} &mdash; opt this operation in.
710    *    <li>{@code "false"} &mdash; opt this operation out (overrides an opted-in resource).
711    *    <li>{@code ""} (default) &mdash; inherit from the resource-level {@code @Rest(problemDetails)}.
712    * </ul>
713    */
714   private final Memoizer<Boolean> problemDetails = memoizer(() -> {
715      var v = findOpString("problemDetails");
716      if (v.isPresent())
717         return Boolean.parseBoolean(v.get());
718      if (isInherited("problemDetails"))
719         return restContext().isProblemDetails();
720      return false;
721   });
722
723   /**
724    * Whether REST-driven serializer/parser debug behavior is enabled for this operation.
725    *
726    * <p>
727    * Tri-state semantics on the {@code @RestOp}-group annotations:
728    * <ul>
729    *    <li>{@code "true"} &mdash; opt this operation in.
730    *    <li>{@code "false"} &mdash; opt this operation out (overrides an opted-in resource).
731    *    <li>{@code ""} (default) &mdash; inherit from the resource-level {@code @Rest(debugMarshalling)}.
732    * </ul>
733    */
734   private final Memoizer<Boolean> debugMarshalling = memoizer(() -> {
735      var v = findOpString("debugMarshalling");
736      if (v.isPresent())
737         return Boolean.parseBoolean(v.get());
738      if (isInherited("debugMarshalling"))
739         return restContext().isDebugMarshalling();
740      return false;
741   });
742
743   /**
744    * Whether this operation opts into per-request virtual-thread dispatch on Java 21+.
745    *
746    * <p>
747    * Tri-state semantics on {@code @RestOp}-group annotations: {@code "true"} enables, {@code "false"} disables
748    * (overrides an opted-in resource), and {@code ""} (default) inherits from {@code @Rest(virtualThreads)}. Honored
749    * only when the resource-level {@link RestContext#getVirtualThreadExecutor()} is non-{@code null} (Java 21+).
750    */
751   private final Memoizer<Boolean> virtualThreadsEnabled = memoizer(() -> {
752      var v = findOpString("virtualThreads");
753      if (v.isPresent())
754         return Boolean.parseBoolean(v.get());
755      if (isInherited("virtualThreads"))
756         return restContext().isVirtualThreadsEnabled();
757      return false;
758   });
759
760   /**
761    * Configurable async-response timeout (milliseconds) for this operation; {@code -1} when neither the op
762    * annotation nor the resource declares a value (so {@code AsyncResponseProcessor}'s 30-second default applies).
763    */
764   private final Memoizer<Long> asyncTimeoutMillis = memoizer(() -> {
765      var v = findOpString("asyncTimeoutMillis");
766      if (v.isPresent()) {
767         try {
768            return Long.parseLong(v.get().trim());
769         } catch (NumberFormatException nfe) {
770            return -1L;
771         }
772      }
773      if (isInherited("asyncTimeoutMillis"))
774         return restContext().getAsyncTimeoutMillis();
775      return -1L;
776   });
777
778   /**
779    * Per-op async-completion executor: op-level bean name overrides {@code @Rest}-level, then both are
780    * resolved against the resource's bean store. {@code null} when unset (natural completion thread).
781    */
782   private final Memoizer<Executor> asyncCompletionExecutor = memoizer(() -> {
783      var v = findOpString("asyncCompletionExecutor");
784      if (v.isPresent()) {
785         var name = v.get();
786         var bs = beanStore();
787         var resolved = bs.getBean(Executor.class, name);
788         if (resolved.isEmpty())
789            resolved = bs.getBean(ExecutorService.class, name).map(e -> (Executor) e);
790         if (resolved.isEmpty())
791            throw new IllegalStateException(
792               "@RestOp(asyncCompletionExecutor) on " + restContext().getResourceClass().getName()
793                  + "#" + method().getName() + " references bean name '" + name + "' but no Executor"
794                  + " (or ExecutorService) bean with that name is registered in the resource's bean store.");
795         return resolved.get();
796      }
797      if (isInherited("asyncCompletionExecutor"))
798         return restContext().getAsyncCompletionExecutor();
799      return null;
800   });
801
802   /**
803    * Effective per-op observability state, resolving verb / {@code @RestOp} annotation override first,
804    * then falling back to the resource-level {@code @Rest(observability)} attribute.
805    *
806    * <ul class='values'>
807    *    <li>{@code "true"} &mdash; strict opt-in; this operation requires a wired backend.
808    *    <li>{@code "false"} &mdash; explicit opt-out; the observability block is short-circuited.
809    *    <li>{@code null} &mdash; default inherit behavior; a missing backend silently falls back to NoOp.
810    * </ul>
811    */
812   private final Memoizer<String> observabilityAttr = memoizer(() -> {
813      var v = findOpString("observability");
814      if (v.isPresent())
815         return v.get();
816      if (isInherited("observability"))
817         return restContext().getObservabilityAttribute();
818      return null;
819   });
820
821   /**
822    * Per-op metric name override from {@code @RestOp(metricName)}.
823    * Empty string (default) means the recorder uses its own default name derivation.
824    */
825   private final Memoizer<String> metricName = memoizer(() ->
826      findOpString("metricName").orElse(""));
827
828   /**
829    * Per-op additional metric tags from {@code @RestOp(metricTags)}.
830    * Format: comma-separated {@code key=value} pairs. Empty string (default) means no additional tags.
831    */
832   private final Memoizer<String> metricTags = memoizer(() ->
833      findOpString("metricTags").orElse(""));
834
835   /** Aggregated {@code noInherit} keys from all RestOp-group annotations on this operation. */
836   private final Memoizer<SortedSet<String>> noInheritOp = memoizer(() -> {
837      var l = getRestOpAnnotations().stream()
838         .map(ai -> ai.getStringArray("noInherit").orElse(StringUtils.EMPTY_STRING_ARRAY))
839         .flatMap(this::resolveCdl)
840         .toList();
841      return u(treeSetCi(l));
842   });
843
844   /** The optional (non-required) matchers extracted from {@link #matchersList}. */
845   private final Memoizer<RestMatcher[]> optionalMatchers = memoizer(() -> matchersList.get().getOptionalEntries());
846
847   /** The invokers for context-level post-call lifecycle methods. */
848   private final Memoizer<RestOpInvoker[]> postCallMethods = memoizer(() ->
849      restContext().getPostCallMethods().stream().map(x -> new RestOpInvoker(x, restContext().findRestOperationArgs(x, opBeanStore()), restContext().getMethodExecStats(x), this::resource)).toArray(RestOpInvoker[]::new)
850   );
851
852   /** The invokers for context-level pre-call lifecycle methods. */
853   private final Memoizer<RestOpInvoker[]> preCallMethods = memoizer(() ->
854      restContext().getPreCallMethods().stream().map(x -> new RestOpInvoker(x, restContext().findRestOperationArgs(x, opBeanStore()), restContext().getMethodExecStats(x), this::resource)).toArray(RestOpInvoker[]::new)
855   );
856
857   /**
858    * The parser group for this operation.
859    *
860    * <p>
861    * Walks the {@code @RestOp(parsers)} chain (parent-to-child); the most-derived non-empty
862    * {@code parsers()} array REPLACES the entire inherited set. Falls through to the class-level
863    * {@link RestContext#getParsers()} when no op annotation declares parsers. An
864    * {@code @Bean ParserSet} bean (matching this operation's method scope) REPLACES the result.
865    */
866   private final Memoizer<ParserSet> parsers = memoizer(() -> {
867      var aa = appliedAnnotations();
868      var bs = beanStore();
869      var pb = restContext().getParsersBuilder();
870      var b = pb.copy();
871      if (pb.canApply(aa))
872         b.apply(aa);
873      getRestOpAnnotationsForProperty("parsers").forEach(ai -> {
874         var c = ai.getClassArray("parsers", Object.class).orElse(null);
875         if (nn(c) && c.length > 0)
876            b.set(c);
877      });
878      var result = Holder.of(b.build());
879      bs.createBeanFromMethod(ParserSet.class, resource(), this::matchesInjectScope)
880         .ifPresent(result::set);
881      return result.get();
882   });
883
884   /** The HTTP part parser for this operation (op-level creator applied on top of the parent). */
885   private final Memoizer<HttpPartParser> partParser = memoizer(() -> {
886      var aa = appliedAnnotations();
887      var parent = restContext().getPartParserCreator();
888      if (!parent.canApply(aa))
889         return restContext().getPartParser();
890      Holder<HttpPartParser.Creator> v = Holder.of(parent.copy());
891      v.get().apply(aa);
892      var bs = new BasicBeanStore(beanStore())
893         .addBean(Method.class, method())
894         .addBean(HttpPartParser.Creator.class, v.get());
895      bs.createBeanFromMethod(HttpPartParser.class, resource(), this::matchesInjectScope)
896         .ifPresent(x -> v.get().impl(x));
897      return v.get().create();
898   });
899
900   /** The HTTP part serializer for this operation (op-level creator applied on top of the parent). */
901   private final Memoizer<HttpPartSerializer> partSerializer = memoizer(() -> {
902      var aa = appliedAnnotations();
903      var parent = restContext().getPartSerializerCreator();
904      if (!parent.canApply(aa))
905         return restContext().getPartSerializer();
906      Holder<HttpPartSerializer.Creator> v = Holder.of(parent.copy());
907      v.get().apply(aa);
908      var bs = new BasicBeanStore(beanStore())
909         .addBean(Method.class, method())
910         .addBean(HttpPartSerializer.Creator.class, v.get());
911      bs.createBeanFromMethod(HttpPartSerializer.class, resource(), this::matchesInjectScope)
912         .ifPresent(x -> v.get().impl(x));
913      return v.get().create();
914   });
915
916   /**
917    * The URL path matchers for this operation.
918    *
919    * <p>
920    * Walks the {@code @RestOp}/verb annotation chain (parent-to-child) and collects each
921    * annotation's {@code path[]} array plus its {@code value()} (the conventional shortcut form on
922    * {@link RestGet @RestGet}/{@link RestPost @RestPost}/etc., or the {@code "METHOD path"} pair on
923    * {@link RestOp @RestOp}). When no explicit paths are declared, the operation method name (with
924    * the verb prefix stripped where applicable) is auto-detected via {@link HttpUtils#detectHttpPath}.
925    * For RRPC operations with no explicit path, a trailing {@code "/*"} is appended so the matcher
926    * matches anything below the method's URL.
927    * {@code noInherit={"path"}} cuts off any further parent-chain contribution. A
928    * {@code @Bean UrlPathMatcherList} bean (matching this operation's method scope) REPLACES
929    * the entire result.
930    *
931    * <p>
932    * SVL variables (e.g. {@code $S{key,default}} or the shorthand {@code ${key:default}}) in path
933    * strings are resolved via the host {@link RestContext}'s {@link VarResolver} before being
934    * compiled into {@link UrlPathMatcher} patterns &mdash; this closes the asymmetry with class-level
935    * {@link Rest#path() @Rest(path)} / {@link Rest#paths() @Rest(paths)}, which are also
936    * SVL-resolved. Strings that contain no SVL markers pass through unchanged, so this is a strict
937    * superset of the previous behavior. Path strings whose entire SVL resolution yields an empty
938    * string are skipped (matching how empty pieces are dropped from the post-comma-split pipeline at
939    * the class level &mdash; see {@code RestContext#splitPathsValue}). Unresolved variables (no
940    * registered {@link Var}) pass through to {@link UrlPathMatcher#of} as the literal {@code ${...}}
941    * placeholder, which then fails predictably at routing time. The auto-detected fallback path
942    * (derived from the method name via {@link HttpUtils#detectHttpPath}) is intentionally
943    * <i>not</i> SVL-resolved &mdash; it is framework-derived, not user input.
944    */
945   private final Memoizer<UrlPathMatcher[]> pathMatchers = memoizer(() -> {
946      var v = Holder.of(UrlPathMatcherList.create());
947      var vr = varResolver();
948      // Use a single VarResolverSession + explicit compile(...) / .resolve(session) per path
949      // so the framework hot path exercises the compiled-template seam explicitly.
950      // Each distinct path string is tokenized once into a VarTemplate, then resolved against
951      // this session. If we ever switch to per-request dynamic path resolution, the seam is
952      // already in place — only the .resolve(session) call moves to the request handler.
953      var session = vr.createSession();
954      getRestOpAnnotationsForProperty("path").forEach(ai -> {
955         for (var p : ai.getStringArray("path").orElse(StringUtils.EMPTY_STRING_ARRAY)) {
956            var resolved = vr.compile(p).resolve(session);
957            if (!resolved.isEmpty())
958               v.get().add(UrlPathMatcher.of(resolved));
959         }
960         // On verb annotations (@RestGet/@RestPost/etc.) value() is always the path. On @RestOp,
961         // value() is "[METHOD] [path]" where the leading method token is optional — only when a
962         // space is present does the trailing token represent a path. To keep this loop annotation-
963         // agnostic, we apply the @RestOp space-split rule only when an @RestOp annotation is in
964         // play (i.e. the annotation type matches), and otherwise treat value() as a plain path.
965         // SVL is applied AFTER the space-split rule so a resolved path can never be misinterpreted
966         // as a "METHOD path" pair (the method token is structural, not user-overridable via SVL).
967         ai.getString("value").filter(StringUtils::isNotBlank).map(String::trim).ifPresent(s -> {
968            if (ai.inner() instanceof RestOp) {
969               var i = s.indexOf(' ');
970               if (i != -1) {
971                  var resolved = vr.compile(s.substring(i).trim()).resolve(session);
972                  if (!resolved.isEmpty())
973                     v.get().add(UrlPathMatcher.of(resolved));
974               }
975            } else {
976               var resolved = vr.compile(s).resolve(session);
977               if (!resolved.isEmpty())
978                  v.get().add(UrlPathMatcher.of(resolved));
979            }
980         });
981      });
982
983      if (v.get().isEmpty()) {
984         var methodInfo2 = MethodInfo.of(method());
985         String httpMethod2 = null;
986         if (methodInfo2.hasAnnotation(RestGet.class))
987            httpMethod2 = "get";
988         else if (methodInfo2.hasAnnotation(RestPut.class))
989            httpMethod2 = "put";
990         else if (methodInfo2.hasAnnotation(RestPost.class))
991            httpMethod2 = "post";
992         else if (methodInfo2.hasAnnotation(RestDelete.class))
993            httpMethod2 = "delete";
994         else if (methodInfo2.hasAnnotation(RestOp.class)) {
995            // @formatter:off
996            httpMethod2 = AP.find(RestOp.class, methodInfo2)
997               .stream()
998               .map(x -> x.inner().method())
999               .filter(Shorts::ine)
1000               .findFirst()
1001               .orElse(null);
1002            // @formatter:on
1003         }
1004
1005         var p = HttpUtils.detectHttpPath(method(), httpMethod2);
1006
1007         // RRPC operations match anything below the method's URL when no explicit path is supplied
1008         if (eqic("RRPC", httpMethod2) && ! p.endsWith("/*"))
1009            p += "/*";
1010
1011         v.get().add(UrlPathMatcher.of(p));
1012      }
1013
1014      beanStore().createBeanFromMethod(UrlPathMatcherList.class, resource(), this::matchesInjectScope, v.get())
1015         .ifPresent(v::set);
1016
1017      // @Mixin(path/paths) re-mount: when this op belongs to a mixin sub-context whose host @Mixin declared a
1018      // mount prefix, re-mount every matcher under each host-chosen prefix.  Reuses the pathToken normalization
1019      // (via RestContext.getMixinMountPrefixes) so override input forms (bare / leading-slash / trailing /*)
1020      // all collapse to one clean prefix.  Build-time only.
1021      var prefixes = restContext().getMixinMountPrefixes();
1022      if (! prefixes.isEmpty()) {
1023         var remounted = UrlPathMatcherList.create();
1024         for (var prefix : prefixes)
1025            for (var pm : v.get().asArray())
1026               remounted.add(UrlPathMatcher.of(prefixPath(prefix, pm.toString())));
1027         v.set(remounted);
1028      }
1029
1030      return v.get().asArray();
1031   });
1032
1033   /**
1034    * Prepends a host-chosen mixin mount prefix to an op path pattern.
1035    *
1036    * <p>
1037    * {@code prefix} is a bare token (no slashes — already normalized by {@code RestContext.getMixinMountPrefixes}).
1038    * The original pattern keeps its leading slash and any trailing {@code /*}; e.g. prefix {@code "admin"} +
1039    * pattern {@code /info} &rarr; {@code /admin/info}, and prefix {@code "admin"} + {@code /loggers/*} &rarr;
1040    * {@code /admin/loggers/*}.
1041    *
1042    * @param prefix The normalized mount-prefix token.
1043    * @param pattern The original op path pattern.
1044    * @return The prefixed pattern.
1045    */
1046   private static String prefixPath(String prefix, String pattern) {
1047      var p = pattern.startsWith("/") ? pattern : "/" + pattern;
1048      return "/" + prefix + p;
1049   }
1050
1051   /** The required matchers extracted from {@link #matchersList}. */
1052   private final Memoizer<RestMatcher[]> requiredMatchers = memoizer(() -> matchersList.get().getRequiredEntries());
1053
1054   // Immutable, cached views over the routing arrays returned by the public getters.  Built once (memoized) so
1055   // there is zero per-call allocation on the routing hot path and no live-array exposure to callers.  Internal
1056   // hot-path code reads the private array memoizers directly; these views exist only for the public API surface.
1057   private final Memoizer<List<UrlPathMatcher>> pathMatchersView = memoizer(() -> List.of(pathMatchers.get()));
1058   private final Memoizer<List<RestMatcher>> optionalMatchersView = memoizer(() -> List.of(optionalMatchers.get()));
1059   private final Memoizer<List<RestMatcher>> requiredMatchersView = memoizer(() -> List.of(requiredMatchers.get()));
1060
1061   /** The computed response metadata for this operation method. */
1062   private final Memoizer<ResponseBeanMeta> responseMeta = memoizer(() -> ResponseBeanMeta.create(methodInfo(), appliedAnnotations()));
1063
1064   /**
1065    * All {@link RestOp}-group annotations on this method, in child-to-parent order.
1066    *
1067    * <p>
1068    * Uses the same {@link MethodInfo} as the annotation traversal in the {@link Builder}, so
1069    * method-level metadata (e.g. {@code noInherit}) resolves consistently when the implementation
1070    * class differs from the method's declaring class.
1071    */
1072   private final Memoizer<List<AnnotationInfo<?>>> restOpAnnotations = memoizer(() -> {
1073      var methodInfo = MethodInfo.of(restContext().getResourceClass(), method()).accessible();
1074      return restContext().getAnnotationProvider().find(methodInfo, SELF, MATCHING_METHODS).stream()
1075         .filter(ai -> ai.isInGroup(RestOp.class))
1076         .toList();
1077   });
1078
1079   /**
1080    * The serializer group for this operation.
1081    *
1082    * <p>
1083    * Walks the {@code @RestOp(serializers)} chain (parent-to-child); the most-derived non-empty
1084    * {@code serializers()} array REPLACES the entire inherited set. Falls through to the class-level
1085    * {@link RestContext#getSerializers()} when no op annotation declares serializers. An
1086    * {@code @Bean SerializerSet} bean (matching this operation's method scope) REPLACES the result.
1087    */
1088   private final Memoizer<SerializerSet> serializers = memoizer(() -> {
1089      var aa = appliedAnnotations();
1090      var bs = beanStore();
1091      var sb = restContext().getSerializersBuilder();
1092      var b = sb.copy();
1093      if (sb.canApply(aa))
1094         b.apply(aa);
1095      getRestOpAnnotationsForProperty("serializers").forEach(ai -> {
1096         var c = ai.getClassArray("serializers", Serializer.class).orElse(null);
1097         if (nn(c) && c.length > 0)
1098            b.set(c);
1099      });
1100      var result = Holder.of(b.build());
1101      bs.createBeanFromMethod(SerializerSet.class, resource(), this::matchesInjectScope)
1102         .ifPresent(result::set);
1103      return result.get();
1104   });
1105
1106   /**
1107    * The supported response accept types for this operation.
1108    *
1109    * <p>
1110    * Walks op-level {@code @RestOp}-group annotations for {@code produces} (and, when
1111    * {@code noInherit} does not block it, the class-level {@code @Rest(produces)} hierarchy).
1112    * Falls back to the supported media types of the operation's {@link SerializerSet}.
1113    */
1114   private final Memoizer<List<MediaType>> supportedAcceptTypes = memoizer(() -> {
1115      var result = collectAnnotationMediaTypes("produces");
1116      if (result.isEmpty())
1117         return u(getSerializers().getSupportedMediaTypes());
1118      return u(result);
1119   });
1120
1121   /**
1122    * The supported request content types for this operation.
1123    *
1124    * <p>
1125    * Walks op-level {@code @RestOp}-group annotations for {@code consumes} (and, when
1126    * {@code noInherit} does not block it, the class-level {@code @Rest(consumes)} hierarchy).
1127    * Falls back to the supported content types of the operation's {@link ParserSet}.
1128    */
1129   private final Memoizer<List<MediaType>> supportedContentTypes = memoizer(() -> {
1130      var result = collectAnnotationMediaTypes("consumes");
1131      if (result.isEmpty())
1132         return u(getParsers().getSupportedMediaTypes());
1133      return u(result);
1134   });
1135
1136   /**
1137    * Iterates over each parameter annotation on the operation method, computing the parameter's
1138    * {@link Schema#default_()}/{@link Schema#df()} string (joined-non-blank-first) and dispatching
1139    * each annotation+default pair to the supplied callback.
1140    */
1141   private void processParameterDefaults(BiConsumer<Annotation,String> callback) {
1142      for (var aa : method.getParameterAnnotations()) {
1143         String def = null;
1144         for (var a : aa) {
1145            if (a instanceof Schema a2)
1146               def = joinnlFirstNonEmptyArray(a2.default_(), a2.df());
1147         }
1148         if (def == null)
1149            continue;
1150         for (var a : aa)
1151            callback.accept(a, def);
1152      }
1153   }
1154
1155   /**
1156    * Returns {@code true} if the given method has a {@code @Bean} annotation whose
1157    * {@code methodScope} includes this operation's method name (or {@code "*"}).
1158    *
1159    * <p>
1160    * Used by op-level memoizers when scanning the resource class for {@code @Bean}-supplied
1161    * composite-bean overrides ({@code RestConverterList}, {@code RestGuardList}, etc.). This is the
1162    * {@link RestOpContext}-scope peer of {@link Builder#matches(MethodInfo)} — kept in sync with that
1163    * one.
1164    */
1165   private boolean matchesInjectScope(MethodInfo annotated) {
1166      var a = annotated.getAnnotations(Bean.class).findFirst().map(AnnotationInfo::inner).orElse(null);
1167      if (a != null) {
1168         for (var n : a.methodScope()) {
1169            if (eq(n, "*") || method.getName().equals(n))
1170               return true;
1171         }
1172      }
1173      return false;
1174   }
1175
1176   /**
1177    * Same as {@link #matchesInjectScope(MethodInfo)} but additionally requires the
1178    * {@link Bean#name()} attribute to equal {@code beanName}.
1179    *
1180    * <p>
1181    * Used for named composite-bean overrides ({@code defaultRequestHeaders}, {@code defaultResponseHeaders},
1182    * etc.) where the bean type alone is ambiguous (e.g. {@link HeaderList} appears as both request and
1183    * response headers).
1184    */
1185   private boolean matchesInjectScope(MethodInfo annotated, String beanName) {
1186      var a = annotated.getAnnotations(Bean.class).findFirst().map(AnnotationInfo::inner).orElse(null);
1187      if (a != null) {
1188         if (! BeanAnnotation.name(a).equals(beanName))
1189            return false;
1190         for (var n : a.methodScope()) {
1191            if (eq(n, "*") || method.getName().equals(n))
1192               return true;
1193         }
1194      }
1195      return false;
1196   }
1197
1198   private Stream<String> resolveCdl(String...values) {
1199      if (values == null || values.length == 0)
1200         return Stream.empty();
1201      return Arrays.stream(values)
1202         .filter(Objects::nonNull)
1203         .map(s -> context.getVarResolver().resolve(s))
1204         .map(StringUtils::split)
1205         .flatMap(Collection::stream)
1206         .map(String::trim)
1207         .filter(StringUtils::isNotBlank);
1208   }
1209
1210   /**
1211    * Returns all {@link RestOp}-group annotations on this operation method, in child-to-parent order.
1212    *
1213    * @return An unmodifiable list, never {@code null}.
1214    */
1215   public List<AnnotationInfo<?>> getRestOpAnnotations() {
1216      return restOpAnnotations.get();
1217   }
1218
1219   /**
1220    * Returns the {@link RestOp}-group annotations on this method for the specified property,
1221    * in <b>parent-to-child</b> order, with op-level {@code noInherit} cutoff applied.
1222    *
1223    * <p>
1224    * Mirrors {@link RestContext#getRestAnnotationsForProperty(String)} but for the op-level chain. Used by
1225    * op-level memoizers ({@link #converters}, {@link #guards}, {@link #matchersList},
1226    * {@link #encoders}, etc.) when accumulating values from each {@code @RestOp} / {@code @RestGet} /
1227    * {@code @RestPut} / {@code @RestPost} / {@code @RestDelete} / {@code @RestPatch} / {@code @RestOptions}
1228    * annotation in the method-override chain.
1229    *
1230    * @param name The annotation property name (e.g. {@code "converters"}, {@code "guards"}).
1231    * @return A stream of {@link AnnotationInfo} entries in parent-to-child order, never {@code null}.
1232    */
1233   Stream<AnnotationInfo<?>> getRestOpAnnotationsForProperty(String name) {
1234      var annotations = getRestOpAnnotations();
1235      var cutoff = annotations.size();
1236      for (var i = 0; i < annotations.size(); i++) {
1237         if (resolveCdl(annotations.get(i).getStringArray("noInherit").orElse(StringUtils.EMPTY_STRING_ARRAY)).anyMatch(name::equalsIgnoreCase)) {
1238            cutoff = i + 1;
1239            break;
1240         }
1241      }
1242      return rstream(annotations.subList(0, cutoff));
1243   }
1244
1245   /**
1246    * Returns {@code true} if context-level values for the given property should be merged.
1247    *
1248    * @param property The annotation attribute name (e.g. {@code "allowedParserOptions"},
1249    *    {@code "defaultCharset"}, {@code "maxInput"}).
1250    * @return {@code true} if {@code noInherit} does not contain this property.
1251    */
1252   protected boolean isInherited(String property) {
1253      return !noInheritOp.get().contains(property);
1254   }
1255
1256   /**
1257    * Returns the parser session-option keys allowed for this operation.
1258    *
1259    * @return An unmodifiable case-insensitive sorted set, never {@code null}.
1260    */
1261   public SortedSet<String> getAllowedParserOptions() {
1262      return allowedParserOptions.get();
1263   }
1264
1265   /**
1266    * Returns the serializer session-option keys allowed for this operation.
1267    *
1268    * @return An unmodifiable case-insensitive sorted set, never {@code null}.
1269    */
1270   public SortedSet<String> getAllowedSerializerOptions() {
1271      return allowedSerializerOptions.get();
1272   }
1273
1274   /**
1275    * Startup-fail: validates that when the effective observability attribute for this op is {@code "true"},
1276    * at least one real backend ({@link org.apache.juneau.rest.server.metrics.MetricsRecorder} or
1277    * {@link org.apache.juneau.rest.server.tracing.TracerHook}) is registered.
1278    *
1279    * <p>
1280    * Called once during construction.  The resource-level check in {@link RestContext#checkObservabilityBackendPresent()}
1281    * already fires for {@code @Rest(observability="true")}; this companion fires for individual
1282    * {@code @RestOp(observability="true")} overrides that elevate a single op into strict mode.
1283    */
1284   private void checkOpObservabilityBackendPresent() {
1285      var attr = observabilityAttr.get();
1286      if (neqic("true", attr))
1287         return;
1288      var bs = beanStore();
1289      var recorder = bs.getBean(org.apache.juneau.rest.server.metrics.MetricsRecorder.class).orElse(null);
1290      var tracer = bs.getBean(org.apache.juneau.rest.server.tracing.TracerHook.class).orElse(null);
1291      boolean hasRecorder = recorder != null && !(recorder instanceof org.apache.juneau.rest.server.metrics.NoOpMetricsRecorder);
1292      boolean hasTracer = tracer != null && !(tracer instanceof org.apache.juneau.rest.server.tracing.NoOpTracerHook);
1293      if (!hasRecorder && !hasTracer)
1294         throw new InternalServerError(
1295            "@RestOp(observability=\"true\") is set on " + context.getResourceClass().getSimpleName() + "." + method.getName()
1296            + " but no MetricsRecorder or TracerHook @Bean is registered. "
1297            + "Either wire a backend or change observability to \"\" (inherit) or \"false\" (opt-out).");
1298   }
1299
1300   /**
1301    * Returns the first non-blank, SVL-resolved value of the given attribute across all
1302    * {@code @RestOp}-group annotations on this method (child-to-parent order).
1303    *
1304    * @param attr The annotation attribute name (e.g. {@code "defaultCharset"}).
1305    * @return The resolved string, or empty if no annotation defines it.
1306    */
1307   private Optional<String> findOpString(String attr) {
1308      var vr = varResolver();
1309      for (var ai : getRestOpAnnotations()) {
1310         var s = ai.getString(attr).orElse("");
1311         if (!s.isEmpty()) {
1312            var resolved = vr.resolve(s);
1313            if (!resolved.isEmpty())
1314               return o(resolved);
1315         }
1316      }
1317      return oe();
1318   }
1319
1320   private List<MediaType> collectAnnotationMediaTypes(String attr) {
1321      var result = new ArrayList<MediaType>();
1322      var vr = varResolver();
1323      // Class-level @Rest(consumes|produces) first (when inheritance is allowed), then op-level overrides append.
1324      if (isInherited(attr)) {
1325         for (var ai : restContext().getRestAnnotations())
1326            appendResolvedMediaTypes(ai, attr, vr, result);
1327      }
1328      for (var ai : getRestOpAnnotations())
1329         appendResolvedMediaTypes(ai, attr, vr, result);
1330      return result;
1331   }
1332
1333   private static void appendResolvedMediaTypes(AnnotationInfo<?> ai, String attr, VarResolver vr, List<MediaType> result) {
1334      var arr = ai.getStringArray(attr).orElse(null);
1335      if (arr == null)
1336         return;
1337      for (var s : arr) {
1338         if (isNotEmpty(s)) {
1339            var resolved = vr.resolve(s);
1340            if (!resolved.isEmpty())
1341               result.add(MediaType.of(resolved));
1342         }
1343      }
1344   }
1345
1346   private static String httpMethodFromAnnotation(Annotation a, VarResolver vr) {
1347      if (a instanceof RestGet)
1348         return "get";
1349      if (a instanceof RestPut)
1350         return "put";
1351      if (a instanceof RestPost)
1352         return "post";
1353      if (a instanceof RestDelete)
1354         return "delete";
1355      if (a instanceof RestPatch)
1356         return "patch";
1357      if (a instanceof RestOptions)
1358         return "options";
1359      if (a instanceof RestOp a2) {
1360         var m = vr.resolve(a2.method());
1361         if (ine(m))
1362            return m;
1363         var s = vr.resolve(a2.value());
1364         if (s != null) {
1365            s = s.trim();
1366            if (!s.isEmpty()) {
1367               var i = s.indexOf(' ');
1368               return i == -1 ? s : s.substring(0, i).trim();
1369            }
1370         }
1371      }
1372      return null;
1373   }
1374
1375   private static String normalizeHttpMethod(String v) {
1376      if (eqic("METHOD", v))
1377         return "*";
1378      return ucr(v);
1379   }
1380
1381   /**
1382    * 2-arg positional context constructor.
1383    *
1384    * <p>
1385    * All operation-level configuration is resolved from {@link RestOp}-group annotations on the method and
1386    * inherited from the parent {@link RestContext}.
1387    *
1388    * @param method The Java method this context represents. Must not be <jk>null</jk>.
1389    * @param context The owning {@link RestContext}. Must not be <jk>null</jk>.
1390    * @throws ServletException If context could not be created.
1391    * @since 9.2.1
1392    */
1393   public RestOpContext(java.lang.reflect.Method method, RestContext context) throws ServletException {
1394      this(new Builder(method, context));
1395   }
1396
1397   /**
1398    * 3-arg positional context constructor.
1399    *
1400    * @param method The Java method this context represents. Must not be <jk>null</jk>.
1401    * @param context The owning {@link RestContext}. Must not be <jk>null</jk>.
1402    * @param resourceSupplier Supplier that returns the invocation target for this operation.
1403    * @throws ServletException If context could not be created.
1404    */
1405   public RestOpContext(java.lang.reflect.Method method, RestContext context, Supplier<Object> resourceSupplier) throws ServletException {
1406      this(new Builder(method, context, resourceSupplier));
1407   }
1408
1409   /**
1410    * Context constructor.
1411    *
1412    * @param builder The builder for this object.
1413    * @throws ServletException If context could not be created.
1414    */
1415   private RestOpContext(Builder builder) throws ServletException {
1416      super(builder);
1417
1418      try {
1419         // Capture the annotation work-list before the builder becomes eligible for GC.
1420         appliedAnnotations = builder.getApplied();
1421
1422         context = builder.restContext;
1423         method = builder.restMethod;
1424         resourceSupplier = builder.restResourceSupplier;
1425
1426         mi = MethodInfo.of(method).accessible();
1427
1428         var bs = new BasicBeanStore(context.getBootstrapBeanStore());
1429         bs.addBean(RestOpContext.class, this);
1430         bs.addBean(Method.class, method);
1431         bs.addBean(AnnotationWorkList.class, appliedAnnotations);
1432         opBeanStore = bs;
1433
1434         bs.add(MarshallingContext.class, getMarshallingContext());
1435         bs.add(RestConverter[].class, getConverters());
1436         bs.add(EncoderSet.class, getEncoders());
1437         bs.add(RestGuard[].class, getGuards());
1438         bs.add(JsonSchemaGenerator.class, getJsonSchemaGenerator());
1439         bs.add(ParserSet.class, getParsers());
1440         bs.add(HttpPartParser.class, getPartParser());
1441         bs.add(HttpPartSerializer.class, getPartSerializer());
1442         bs.add(SerializerSet.class, getSerializers());
1443
1444      // Inject @Value-annotated env-driven defaults onto this RestOpContext instance so that the
1445      // memoizer lambdas (defaultCharset, maxInput) read fully resolved values when first invoked.
1446      ClassInfo.of(this).inject(this, bs);
1447
1448      // Startup-fail check: if this op (or its resource) declares observability="true" but neither
1449      // MetricsRecorder nor TracerHook is wired, fail fast with a precise error.
1450      checkOpObservabilityBackendPresent();
1451
1452      // The 6 formerly-eager scalar fields are now memoized; no eagerness needed here.
1453      // Pre-warm httpMethod so it is in the memoizer cache for immediate use by compareTo/match.
1454      httpMethod.get();
1455
1456      // Fail servlet init (not the first request) if a concrete @Request bean parameter has a getter with no matching
1457      // setter or no public no-arg constructor; the arg is rebuilt lazily with the invoker.
1458      for (var pi : mi.getParameters())
1459         org.apache.juneau.rest.server.arg.RequestBeanArg.create(pi, appliedAnnotations);
1460
1461         var pm = pathMatchers.get();
1462         bs.add(UrlPathMatcher[].class, pm);
1463         bs.addBean(UrlPathMatcher.class, pm.length > 0 ? pm[0] : null);
1464
1465      } catch (Exception e) {
1466         throw new ServletException(e);
1467      }
1468   }
1469
1470   /*
1471    * compareTo() method is used to keep SimpleMethods ordered in the RestCallRouter list.
1472    * It maintains the order in which matches are made during requests.
1473    */
1474   @Override /* Overridden from Comparable */
1475   public int compareTo(RestOpContext o) {
1476      int c;
1477
1478      var pm = pathMatchers.get();
1479      var opm = o.pathMatchers.get();
1480      for (int i = 0; i < Math.min(pm.length, opm.length); i++) {
1481         c = pm[i].compareTo(opm[i]);
1482         if (c != 0)
1483            return c;
1484      }
1485
1486      c = cmp(o.hierarchyDepth.get(), hierarchyDepth.get());
1487      if (c != 0)
1488         return c;
1489
1490      c = cmp(o.requiredMatchers.get().length, requiredMatchers.get().length);
1491      if (c != 0)
1492         return c;
1493
1494      c = cmp(o.optionalMatchers.get().length, optionalMatchers.get().length);
1495      if (c != 0)
1496         return c;
1497
1498      c = cmp(o.getGuards().length, getGuards().length);
1499
1500      if (c != 0)
1501         return c;
1502
1503      c = cmp(method.getName(), o.method.getName());
1504      if (c != 0)
1505         return c;
1506
1507      c = cmp(method.getParameterCount(), o.method.getParameterCount());
1508      if (c != 0)
1509         return c;
1510
1511      for (var i = 0; i < method.getParameterCount(); i++) {
1512         c = cmp(method.getParameterTypes()[i].getName(), o.method.getParameterTypes()[i].getName());
1513         if (c != 0)
1514            return c;
1515      }
1516
1517      c = cmp(method.getReturnType().getName(), o.method.getReturnType().getName());
1518      if (c != 0)
1519         return c;
1520
1521      // Declaring-class tie-breaker: keeps compareTo consistent with equals()/hashCode() (which key off method
1522      // identity) for two ops whose methods share the same signature but differ by declaring class.  Only affects
1523      // ordering that was previously a zero-tie, so it makes tie-break stability deterministic rather than changing
1524      // any non-tied dispatch precedence.
1525      c = cmp(method.getDeclaringClass().getName(), o.method.getDeclaringClass().getName());
1526      if (c != 0)
1527         return c;
1528
1529      return 0;
1530   }
1531
1532   @Override /* Overridden from Context */
1533   public Context.Builder copy() {
1534      throw uoex();
1535   }
1536
1537   /**
1538    * Creates a {@link RestRequest} object based on the specified incoming {@link HttpServletRequest} object.
1539    *
1540    * @param session The current REST call.
1541    * @return The wrapped request object.
1542    * @throws Exception If any errors occur trying to interpret the request.
1543    */
1544   public RestRequest createRequest(RestSession session) throws Exception {
1545      return new RestRequest(this, session);
1546   }
1547
1548   /**
1549    * Creates a {@link RestResponse} object based on the specified incoming {@link HttpServletResponse} object
1550    * and the request returned by {@link #createRequest(RestSession)}.
1551    *
1552    * @param session The current REST call.
1553    * @param req The REST request.
1554    * @return The wrapped response object.
1555    * @throws Exception If any errors occur trying to interpret the request or response.
1556    */
1557   public RestResponse createResponse(RestSession session, RestRequest req) throws Exception {
1558      return new RestResponse(this, session, req);
1559   }
1560
1561   /**
1562    * Creates a new REST operation session.
1563    *
1564    * @param session The REST session.
1565    * @return A new REST operation session.
1566    * @throws Exception If op session could not be created.
1567    */
1568   public RestOpSession.Builder createSession(RestSession session) throws Exception {
1569      var b = RestOpSession.create(this, session);
1570      if (getLogger().isLoggable(Level.FINEST))
1571         session.installCapture();
1572      return b;
1573   }
1574
1575   @Override /* Overridden from Object */
1576   public boolean equals(Object o) {
1577      return (o instanceof RestOpContext o2) && eq(this, o2, (x, y) -> eq(x.method, y.method));
1578   }
1579
1580   /**
1581    * Returns the marshalling context associated with this context.
1582    *
1583    * @return The marshalling context associated with this context.
1584    */
1585   public MarshallingContext getMarshallingContext() { return marshallingContext.get(); }
1586
1587   /**
1588    * Returns the default charset.
1589    *
1590    * @return The default charset.  Never <jk>null</jk>.
1591    */
1592   public Charset getDefaultCharset() { return defaultCharset.get(); }
1593
1594   /**
1595    * Returns the default request attributes.
1596    *
1597    * @return The default request attributes.  Never <jk>null</jk>.
1598    */
1599   public NamedAttributeMap getDefaultRequestAttributes() { return defaultRequestAttributes.get(); }
1600
1601   /**
1602    * Returns the default form data parameters.
1603    *
1604    * @return The default form data parameters.  Never <jk>null</jk>.
1605    */
1606   public HttpPartList getDefaultRequestFormData() { return defaultRequestFormData.get(); }
1607
1608   /**
1609    * Returns the default request headers.
1610    *
1611    * @return The default request headers.  Never <jk>null</jk>.
1612    */
1613   public HttpHeaderList getDefaultRequestHeaders() { return defaultRequestHeaders.get(); }
1614
1615   /**
1616    * Returns the default request query parameters.
1617    *
1618    * @return The default request query parameters.  Never <jk>null</jk>.
1619    */
1620   public HttpPartList getDefaultRequestQueryData() { return defaultRequestQueryData.get(); }
1621
1622   /**
1623    * Returns the default response headers.
1624    *
1625    * @return The default response headers.  Never <jk>null</jk>.
1626    */
1627   public HttpHeaderList getDefaultResponseHeaders() { return defaultResponseHeaders.get(); }
1628
1629   /**
1630    * Returns the compression encoders to use for this method.
1631    *
1632    * @return The compression encoders to use for this method.
1633    */
1634   public EncoderSet getEncoders() { return encoders.get(); }
1635
1636   /**
1637    * Returns the HTTP method name (e.g. <js>"GET"</js>).
1638    *
1639    * @return The HTTP method name.
1640    */
1641   public String getHttpMethod() { return httpMethod.get(); }
1642
1643   /**
1644    * Returns whether this operation is declared {@link RestOp#read() read=true}.
1645    *
1646    * <p>
1647    * A read operation may be reached by overriding a safe request (<c>GET</c>, <c>HEAD</c>, <c>OPTIONS</c>,
1648    * <c>TRACE</c>) to its custom method.
1649    *
1650    * @return <jk>true</jk> if this operation is declared a read.
1651    * @throws InternalServerError If <c>read=<jk>true</jk></c> is declared on an inherited <ja>@RestOp</ja> that names
1652    *    no method.
1653    * @since 10.0.0
1654    */
1655   public boolean isRead() { return read.get(); }
1656
1657   /**
1658    * Returns the underlying Java method that this context belongs to.
1659    *
1660    * @return The underlying Java method that this context belongs to.
1661    */
1662   public Method getJavaMethod() { return method; }
1663
1664   /**
1665    * Returns the JSON-Schema generator applicable to this Java method.
1666    *
1667    * @return The JSON-Schema generator applicable to this Java method.
1668    */
1669   public JsonSchemaGenerator getJsonSchemaGenerator() { return jsonSchemaGenerator.get(); }
1670
1671   /**
1672    * Returns the max number of bytes to process in the input content.
1673    *
1674    * @return The max number of bytes to process in the input content.
1675    */
1676   public long getMaxInput() { return maxInput.get(); }
1677
1678   /**
1679    * Returns whether this operation emits RFC 7807 {@code application/problem+json} responses.
1680    *
1681    * <p>
1682    * Tri-state inheritance semantics: an explicit {@code "true"} or {@code "false"} on the operation annotation
1683    * wins; otherwise the value is inherited from {@link RestContext#isProblemDetails()} (resource-level
1684    * {@code @Rest(problemDetails)}).
1685    *
1686    * <h5 class='section'>See Also:</h5><ul>
1687    *    <li class='ja'>{@link Rest#problemDetails()}
1688    *    <li class='ja'>{@link RestOp#problemDetails()}
1689    * </ul>
1690    *
1691    * @return <jk>true</jk> if RFC 7807 problem-details responses are enabled on this operation.
1692    */
1693   public boolean isProblemDetails() { return problemDetails.get(); }
1694
1695   /**
1696    * Returns whether REST-driven serializer/parser debug behavior is enabled for this operation.
1697    *
1698    * <p>
1699    * Resolved via the tri-state {@code @RestOp}-group {@code debugMarshalling} attribute ({@code "true"} wins,
1700    * {@code "false"} opts out, {@code ""} inherits the resource-level {@code @Rest(debugMarshalling)}), independent
1701    * of the JUL logger level.
1702    *
1703    * <h5 class='section'>See Also:</h5><ul>
1704    *    <li class='ja'>{@link Rest#debugMarshalling()}
1705    *    <li class='ja'>{@link RestOp#debugMarshalling()}
1706    * </ul>
1707    *
1708    * @return <jk>true</jk> if REST-driven marshalling debug is enabled for this operation.
1709    */
1710   public boolean isDebugMarshalling() { return debugMarshalling.get(); }
1711
1712   /**
1713    * Returns whether this operation opts into per-request virtual-thread dispatch on Java 21+.
1714    *
1715    * <p>
1716    * The op-level {@code @RestOp(virtualThreads)} value (when non-blank) wins; otherwise the value is inherited
1717    * from {@link RestContext#isVirtualThreadsEnabled()} (resource-level {@code @Rest(virtualThreads)}). Honored
1718    * only when the resource-level {@link RestContext#getVirtualThreadExecutor()} is non-{@code null} (Java 21+).
1719    *
1720    * @return <jk>true</jk> if virtual-thread dispatch is configured on this operation.
1721    */
1722   public boolean isVirtualThreadsEnabled() { return virtualThreadsEnabled.get(); }
1723
1724   /**
1725    * Returns whether the observability block is enabled for this operation.
1726    *
1727    * <p>
1728    * Returns {@code false} when the effective resolved attribute is {@code "false"} (explicit opt-out).
1729    * Returns {@code true} in all other cases ({@code "true"} strict opt-in or empty/null default).
1730    *
1731    * <p>
1732    * Note: the default behavior (empty/null attribute) still runs the observability block, but silently
1733    * falls back to {@link org.apache.juneau.rest.server.metrics.NoOpMetricsRecorder} when no backend is wired.
1734    * Only {@code "false"} completely short-circuits the block.
1735    *
1736    * @return <jk>false</jk> when this operation has explicitly opted out of observability.
1737    * @see RestOp#observability()
1738    * @see Rest#observability()
1739    */
1740   public boolean isObservabilityEnabled() {
1741      var v = observabilityAttr.get();
1742      return neqic("false", v);
1743   }
1744
1745   /**
1746    * Returns the per-op metric name override from {@code @RestOp(metricName)}.
1747    *
1748    * @return The metric name override, or an empty string when not set.
1749    * @see RestOp#metricName()
1750    */
1751   public String getMetricName() { return metricName.get(); }
1752
1753   /**
1754    * Returns the per-op additional metric tags from {@code @RestOp(metricTags)}.
1755    *
1756    * @return The tags string (comma-separated {@code key=value} pairs), or empty string when not set.
1757    * @see RestOp#metricTags()
1758    */
1759   public String getMetricTags() { return metricTags.get(); }
1760
1761   /**
1762    * Returns the configured async-response timeout (milliseconds) for this operation, or {@code -1} when no
1763    * value was supplied at the op or resource level — in which case the default 30-second fallback applies in
1764    * {@link org.apache.juneau.rest.server.processor.AsyncResponseProcessor}.
1765    *
1766    * @return The async timeout in milliseconds, or {@code -1} when unset.
1767    */
1768   public long getAsyncTimeoutMillis() { return asyncTimeoutMillis.get(); }
1769
1770   /**
1771    * Returns the effective {@link Executor} for routing
1772    * {@link CompletableFuture} completion callbacks on this operation, or
1773    * {@code null} when neither the op nor the resource configures an executor (natural completion thread).
1774    *
1775    * @return The executor, or {@code null} if no async-completion executor is configured.
1776    * @since 10.0.0
1777    */
1778   public Executor getAsyncCompletionExecutor() { return asyncCompletionExecutor.get(); }
1779
1780   /**
1781    * Returns the parsers to use for this method.
1782    *
1783    * @return The parsers to use for this method.
1784    */
1785   public ParserSet getParsers() { return parsers.get(); }
1786
1787   /**
1788    * Bean property getter:  <property>partParser</property>.
1789    *
1790    * @return The value of the <property>partParser</property> property on this bean, or <jk>null</jk> if it is not set.
1791    */
1792   public HttpPartParser getPartParser() { return partParser.get(); }
1793
1794   /**
1795    * Bean property getter:  <property>partSerializer</property>.
1796    *
1797    * @return The value of the <property>partSerializer</property> property on this bean, or <jk>null</jk> if it is not set.
1798    */
1799   public HttpPartSerializer getPartSerializer() { return partSerializer.get(); }
1800
1801   /**
1802    * Returns the path pattern for this method.
1803    *
1804    * @return The path pattern.
1805    */
1806   public String getPathPattern() { return pathMatchers.get()[0].toString(); }
1807
1808   /**
1809    * Returns the URL path matchers for this operation.
1810    *
1811    * @return The URL path matchers for this operation.
1812    *    <br>Never <jk>null</jk>.
1813    *    <br>The returned list is an immutable, cached view.
1814    */
1815   public List<UrlPathMatcher> getPathMatchers() { return pathMatchersView.get(); }
1816
1817   /**
1818    * Returns the optional matchers for this operation.
1819    *
1820    * @return The optional matchers for this operation.
1821    *    <br>Never <jk>null</jk>.
1822    *    <br>The returned list is an immutable, cached view.
1823    */
1824   public List<RestMatcher> getOptionalMatchers() { return optionalMatchersView.get(); }
1825
1826   /**
1827    * Returns the required matchers for this operation.
1828    *
1829    * @return The required matchers for this operation.
1830    *    <br>Never <jk>null</jk>.
1831    *    <br>The returned list is an immutable, cached view.
1832    */
1833   public List<RestMatcher> getRequiredMatchers() { return requiredMatchersView.get(); }
1834
1835   /**
1836    * Returns the per-operation logger used for debug capture.
1837    *
1838    * <p>
1839    * A hierarchical child of the host resource's resolved logger ({@code <hostLoggerName>.<methodName>}) &mdash;
1840    * see the {@code logger} memoizer for how the host's bean-store-overridden {@link RichLogger} (if any) is
1841    * honored.
1842    *
1843    * @return The per-operation logger.
1844    *    <br>Never <jk>null</jk>.
1845    */
1846   public RichLogger getLogger() { return logger.get(); }
1847
1848   /**
1849    * Returns metadata about the specified response object if it's annotated with {@link Response @Response}.
1850    *
1851    * @param o The response POJO.  Can be <jk>null</jk> (returns <jk>null</jk>).
1852    * @return Metadata about the specified response object, or <jk>null</jk> if it's not annotated with {@link Response @Response}.
1853    */
1854   public ResponseBeanMeta getResponseBeanMeta(Object o) {
1855      if (o == null)
1856         return null;
1857      var c = o.getClass();
1858      var rbm = responseBeanMetas.get(c);
1859      if (rbm == null) {
1860         rbm = ResponseBeanMeta.create(c, AnnotationWorkList.create());
1861         if (rbm == null)
1862            rbm = ResponseBeanMeta.NULL;
1863         responseBeanMetas.put(c, rbm);
1864      }
1865      if (rbm == ResponseBeanMeta.NULL)
1866         return null;
1867      return rbm;
1868   }
1869
1870   /**
1871    * Returns metadata about the specified response object if it's annotated with {@link Header @Header}.
1872    *
1873    * @param o The response POJO.  Can be <jk>null</jk> (returns <jk>null</jk>).
1874    * @return Metadata about the specified response object, or <jk>null</jk> if it's not annotated with {@link Header @Header}.
1875    */
1876   public ResponsePartMeta getResponseHeaderMeta(Object o) {
1877      if (o == null)
1878         return null;
1879      var c = o.getClass();
1880      var pm = headerPartMetas.get(c);
1881      if (pm == null) {
1882         var a = ClassInfo.of(c).getAnnotations(Header.class).findFirst().map(AnnotationInfo::inner).orElse(null);
1883         if (nn(a)) {
1884            var schema = HttpPartSchema.create(a);
1885         var serializer = createPartSerializer((Class<? extends HttpPartSerializer>)schema.getSerializer(), getPartSerializer());
1886            pm = new ResponsePartMeta(HEADER, schema, serializer);
1887         }
1888         if (pm == null)
1889            pm = ResponsePartMeta.NULL;
1890         headerPartMetas.put(c, pm);
1891      }
1892      if (pm == ResponsePartMeta.NULL)
1893         return null;
1894      return pm;
1895   }
1896
1897   /**
1898    * Returns the response bean meta if this method returns a {@link Response}-annotated bean.
1899    *
1900    * @return The response bean meta or <jk>null</jk> if it's not a {@link Response}-annotated bean.
1901    */
1902   public ResponseBeanMeta getResponseMeta() { return responseMeta.get(); }
1903
1904   /**
1905    * Returns the serializers to use for this method.
1906    *
1907    * @return The serializers to use for this method.
1908    */
1909   public SerializerSet getSerializers() { return serializers.get(); }
1910
1911   /**
1912    * Returns a list of supported accept types.
1913    *
1914    * @return An unmodifiable list.
1915    */
1916   public List<MediaType> getSupportedAcceptTypes() { return supportedAcceptTypes.get(); }
1917
1918   /**
1919    * Returns the list of supported content types.
1920    *
1921    * @return An unmodifiable list.
1922    */
1923   public List<MediaType> getSupportedContentTypes() { return supportedContentTypes.get(); }
1924
1925   @Override /* Overridden from Object */
1926   public int hashCode() {
1927      return method.hashCode();
1928   }
1929
1930   private static String toPartValue(Object value) {
1931      return value == null ? null : value.toString();
1932   }
1933
1934   private UrlPathMatch matchPattern(RestSession call) {
1935      for (var pp : pathMatchers.get()) {
1936         var pm = pp.match(call.getUrlPath());
1937         if (pm != null)
1938            return pm;
1939      }
1940      return null;
1941   }
1942
1943   /**
1944    * Whether this operation's path pattern matches the specified call.
1945    *
1946    * <p>
1947    * Unlike {@link #match(RestSession)}, this neither runs the matchers nor caches the path match on the session, so
1948    * it can be asked of any operation without disturbing the one about to run.
1949    *
1950    * @param session The call to check.
1951    * @return <jk>true</jk> if the path pattern matches.
1952    */
1953   boolean matchesPath(RestSession session) {
1954      return nn(matchPattern(session));
1955   }
1956
1957   /**
1958    * Identifies if this method can process the specified call.
1959    *
1960    * <p>
1961    * To process the call, the following must be true:
1962    * <ul>
1963    *    <li>Path pattern must match.
1964    *    <li>Matchers (if any) must match.
1965    * </ul>
1966    *
1967    * @param session The call to check.
1968    * @return
1969    *    One of the following values:
1970    *    <ul>
1971    *       <li><c>0</c> - Path doesn't match.
1972    *       <li><c>1</c> - Path matched but matchers did not.
1973    *       <li><c>2</c> - Matches.
1974    *    </ul>
1975    */
1976   protected int match(RestSession session) {
1977
1978      var pm = matchPattern(session);
1979
1980      if (pm == null)
1981         return 0;
1982
1983      var rm = requiredMatchers.get();
1984      var om = optionalMatchers.get();
1985      if (rm.length == 0 && om.length == 0) {
1986         session.urlPathMatch(pm);  // Cache so we don't have to recalculate.
1987         return 2;
1988      }
1989
1990      try {
1991         var req = session.getRequest();
1992
1993         // If the method implements matchers, test them.
1994         for (var m : rm)
1995            if (! m.matches(req))
1996               return 1;
1997         if (om.length > 0) {
1998            var matches = false;
1999            for (var m : om)
2000               matches |= m.matches(req);
2001            if (! matches)
2002               return 1;
2003      }
2004
2005         session.urlPathMatch(pm);  // Cache so we don't have to recalculate.
2006         return 2;
2007      } catch (Exception e) {
2008         throw new InternalServerError(e);
2009      }
2010   }
2011
2012   @Override /* Overridden from Context */
2013   protected FluentMap<String,Object> properties() {
2014      return super.properties()
2015         .a("defaultRequestFormData", getDefaultRequestFormData())
2016         .a("defaultRequestHeaders", getDefaultRequestHeaders())
2017         .a("defaultRequestQueryData", getDefaultRequestQueryData())
2018         .a("httpMethod", getHttpMethod());
2019   }
2020
2021   RestConverter[] getConverters() { return converters.get(); }
2022
2023   RestGuard[] getGuards() { return guards.get(); }
2024
2025   RestOpInvoker getMethodInvoker() { return methodInvoker.get(); }
2026
2027   RestOpInvoker[] getPostCallMethods() { return postCallMethods.get(); }
2028
2029   RestOpInvoker[] getPreCallMethods() { return preCallMethods.get(); }
2030   
2031   private static String joinnlFirstNonEmptyArray(String[]...s) {
2032      for (var ss : s)
2033         if (ss.length > 0)
2034            return joinnl(ss);
2035      return null;
2036   }
2037
2038   private static <T> Class<? extends T>[] classArray(Class<? extends T> value) {
2039      return (Class<? extends T>[])new Class<?>[] { value };
2040   }
2041
2042
2043}