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.utils.CollectionUtils.*;
020import static org.apache.juneau.commons.utils.Shorts.*;
021import static org.apache.juneau.commons.utils.StringUtils.*;
022import static org.apache.juneau.commons.utils.StringUtils.isNotEmpty;
023
024import java.lang.reflect.*;
025import java.util.*;
026import java.util.function.*;
027import java.util.logging.*;
028
029import org.apache.juneau.commons.inject.*;
030import org.apache.juneau.commons.reflect.*;
031import org.apache.juneau.rest.server.util.*;
032
033import jakarta.servlet.*;
034
035/**
036 * Implements the child resources of a {@link Rest}-annotated class.
037 *
038 * <p>
039 * Holds a registry of {@link RestContext} instances keyed by {@link RestContext#getPath() composed path}.
040 * Reads (request routing via {@link #findMatch(RestSession.Builder)} and the {@link #asMap()} accessor) are
041 * lock-free against a volatile copy-on-write snapshot; mutations
042 * ({@link #addChild(Class) addChild} / {@link #removeChild(String) removeChild}) synchronize on an internal
043 * write lock and atomically replace the snapshot. This makes runtime child management safe even while
044 * requests are in flight on other threads.
045 *
046 * <p>
047 * Supports lazy-init children registered via {@link Builder#addLazy(Class, String)} — these children have their
048 * {@link RestContext} constructed on the first matching request rather than at parent startup.  The routing entry
049 * (path prefix matcher) is always populated at startup; only the full context build is deferred.
050 *
051 * <h5 class='section'>See Also:</h5><ul>
052 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestAnnotatedClasses">@Rest-Annotated Class</a>
053 * </ul>
054 */
055@SuppressWarnings({
056   "java:S3077" // The volatile Map snapshot and volatile RestContext are published immutable/fully built (copy-on-write).
057})
058public class RestChildren {
059
060   private static final Logger LOGGER = Logger.getLogger(RestChildren.class.getName());
061
062   /**
063    * Holder for a lazy-init child resource entry.
064    *
065    * <p>
066    * At parent startup, only the path prefix and {@link UrlPathMatcher} are populated.  The full
067    * {@link RestContext} is built on the first request that matches the path prefix; subsequent requests
068    * reuse the cached context.  Two concurrent first-requests serialize on {@link #lock} so that exactly
069    * one materialization runs.
070    *
071    * <p>
072    * If materialization throws, the exception propagates to the caller as a {@link jakarta.servlet.ServletException};
073    * the {@link #materialized} field is left {@code null}, so the next request will retry.
074    */
075   static class LazyChildEntry {
076
077      final RestContext.ResolvedChild resolvedChild;
078      final Class<?> resourceClass;
079      final String path;
080      final UrlPathMatcher pathMatcher;
081      final RestContext parent;
082      final BeanStore beanStore;
083      final ServletConfig servletConfig;
084
085      /** Written once under {@link #lock}; volatile for lock-free reads. */
086      volatile RestContext materialized;
087
088      private final Object lock = new Object();
089
090      LazyChildEntry(RestContext.ResolvedChild resolvedChild, String path, RestContext parent, BeanStore beanStore, ServletConfig servletConfig) {
091         this.resolvedChild = resolvedChild;
092         this.resourceClass = resolvedChild.type();
093         this.path = path;
094         var p = path;
095         if (! p.endsWith("/*"))
096            p += "/*";
097         this.pathMatcher = UrlPathMatcher.of(p);
098         this.parent = parent;
099         this.beanStore = beanStore;
100         this.servletConfig = servletConfig;
101      }
102
103      boolean isMaterialized() { return materialized != null; }
104
105      /**
106       * Returns the materialized {@link RestContext}, building it if this is the first call.
107       *
108       * <p>
109       * Thread-safe: at most one thread runs the construction; others block on {@link #lock}.
110       * If construction fails the field is left {@code null}; the next request will retry.
111       *
112       * @return The materialized context. Never {@code null} on success.
113       * @throws ServletException If context construction fails.
114       */
115      RestContext materialize() throws ServletException {
116         RestContext rc = materialized;
117         if (rc != null)
118            return rc;
119         synchronized (lock) {
120            rc = materialized;
121            if (rc != null)
122               return rc;
123            LOGGER.info(() -> "Lazy REST child materializing: " + resourceClass.getName() + " at path '" + path + "'");
124            try {
125               rc = buildChildContext(parent, beanStore, servletConfig, resolvedChild, null, "");
126               rc.postInit();
127               rc.postInitChildFirst();
128            } catch (Exception e) {
129               throw new ServletException("Failed to lazily materialize child REST context for " + resourceClass.getName(), unwrapThrowable(e));
130            }
131            materialized = rc;
132            LOGGER.info(() -> "Lazy REST child materialized: " + resourceClass.getName() + " at path '" + path + "'");
133            return rc;
134         }
135      }
136
137      /**
138       * Resolves the path prefix for a resource class by walking its annotation hierarchy.
139       *
140       * <p>
141       * Replicates the most-derived-first walk that {@link RestContext} performs during full construction,
142       * but does so without instantiating a {@link RestContext} — enabling startup-time path extraction
143       * for lazy children.
144       *
145       * @param resourceClass The resource class. Must not be {@code null}.
146       * @return The trimmed path (without leading slash). Empty string if no {@code @Rest(path=...)} is found.
147       */
148      static String resolvePathForClass(Class<?> resourceClass) {
149         for (Class<?> c = resourceClass; c != null; c = c.getSuperclass()) {
150            Rest r = c.getDeclaredAnnotation(Rest.class);
151            if (r != null && isNotEmpty(r.path()))
152               return trimLeadingSlashes(r.path());
153         }
154         for (Class<?> iface : resourceClass.getInterfaces()) {
155            Rest r = iface.getDeclaredAnnotation(Rest.class);
156            if (r != null && isNotEmpty(r.path()))
157               return trimLeadingSlashes(r.path());
158         }
159         return "";
160      }
161   }
162
163   /**
164    * Builder class.
165    */
166   public static class Builder {
167
168      final RestContext parent;
169      final BeanStore beanStore;
170      final ServletConfig servletConfig;
171      final List<RestContext> list;
172      final List<LazyChildEntry> lazyList;
173
174      /**
175       * Constructor.
176       *
177       * @param parent The parent {@link RestContext} that owns these children. Used for parent-context wiring
178       *    on dynamically added children. May be {@code null} when constructing from contexts already built
179       *    externally and runtime add/remove is not required.
180       * @param beanStore The bean store used for dependency injection on dynamically added children. Must not be
181       *    {@code null}.
182       * @param servletConfig The {@link ServletConfig} passed through to dynamically added child contexts.
183       *    May be {@code null}.
184       */
185      protected Builder(RestContext parent, BeanStore beanStore, ServletConfig servletConfig) {
186         this.parent = parent;
187         this.beanStore = beanStore;
188         this.servletConfig = servletConfig;
189         this.list = list();
190         this.lazyList = list();
191      }
192
193      /**
194       * Returns the bean store used by this builder.
195       *
196       * @return The bean store used by this builder.
197       */
198      public BeanStore beanStore() {
199         return beanStore;
200      }
201
202      /**
203       * Adds an eager child resource to this builder.
204       *
205       * @param value The REST context of the child resource.
206       * @return This object.
207       */
208      public Builder add(RestContext value) {
209         list.add(value);
210         return this;
211      }
212
213      /**
214       * Registers a lazy child resource entry.
215       *
216       * <p>
217       * The path is pre-resolved from the resource class so that routing works immediately at parent startup.
218       * The full {@link RestContext} is built on the first matching request.
219       *
220       * <p>
221       * Delegates to {@link #addLazy(RestContext.ResolvedChild, String)} with a bare (no-seed) declaration —
222       * this public overload's signature is preserved unchanged (it has no external-caller-visible way to
223       * express a host-declared {@code @Child} seed; see that overload for the seed-carrying entry point used
224       * internally by {@link RestContext}'s {@code childrenDefs} discovery).
225       *
226       * @param resourceClass The resource class to materialize lazily. Must not be {@code null}.
227       * @param path The pre-resolved path prefix (without leading slash). Use {@code ""} to read from
228       *    {@link Rest#path() @Rest(path)} on the resource class.
229       * @return This object.
230       */
231      public Builder addLazy(Class<?> resourceClass, String path) {
232         return addLazy(RestContext.ResolvedChild.ofBare(resourceClass), path);
233      }
234
235      /**
236       * Registers a lazy child resource entry carrying a normalized child declaration, which may include a
237       * host-declared {@code @Child} seed.
238       *
239       * <p>
240       * The path is pre-resolved from the resource class so that routing works immediately at parent startup.
241       * The full {@link RestContext} is built on the first matching request, with the seed (if any) threaded
242       * through to {@link RestChildren#buildChildContext} at that point — so a lazily-materialized child
243       * receives its host-seeded settings identically to an eagerly-built one.
244       *
245       * @param resolvedChild The normalized child declaration (class plus any host-declared seed). Must not be
246       *    {@code null}.
247       * @param path The pre-resolved path prefix (without leading slash). Use {@code ""} to read from
248       *    {@link Rest#path() @Rest(path)} on the resource class.
249       * @return This object.
250       */
251      Builder addLazy(RestContext.ResolvedChild resolvedChild, String path) {
252         var resourceClass = resolvedChild.type();
253         var resolvedPath = isNotEmpty(path) ? trimLeadingSlashes(path) : LazyChildEntry.resolvePathForClass(resourceClass);
254         lazyList.add(new LazyChildEntry(resolvedChild, resolvedPath, parent, beanStore, servletConfig));
255         return this;
256      }
257
258      /**
259       * Builds the children.
260       *
261       * @return A new {@link RestChildren}.
262       */
263      public RestChildren build() {
264         return new RestChildren(this);
265      }
266   }
267
268   /**
269    * Static creator.
270    *
271    * @param parent The parent {@link RestContext}. Required for runtime {@link #addChild(Class) addChild} support
272    *    (may be {@code null} otherwise).
273    * @param beanStore The bean store used for child instantiation.
274    * @param servletConfig The {@link ServletConfig} propagated to dynamically added children. May be {@code null}.
275    * @return A new builder for this object.
276    */
277   public static Builder create(RestContext parent, BeanStore beanStore, ServletConfig servletConfig) {
278      return new Builder(parent, beanStore, servletConfig);
279   }
280
281   private final RestContext parent;
282   private final BeanStore beanStore;
283   private final ServletConfig servletConfig;
284   private final Object writeLock = new Object();
285
286   /** Eager-init children keyed by composed path. Copy-on-write; volatile for lock-free reads. */
287   private volatile Map<String,RestContext> children;
288
289   /**
290    * Lazy-init children keyed by path prefix.
291    *
292    * <p>
293    * These entries are registered at parent startup for routing purposes; their {@link RestContext} bodies
294    * are built on first use.  Once materialized, the entry's {@link LazyChildEntry#materialized} field is set
295    * but the entry remains in this map so subsequent reads are lock-free (volatile field read on the entry).
296    * This map itself is never mutated after construction, so no copy-on-write is needed.
297    */
298   private final Map<String,LazyChildEntry> lazyEntries;
299
300   /**
301    * Constructor.
302    *
303    * @param builder The builder containing the settings for this object. Must not be <jk>null</jk>.
304    */
305   public RestChildren(Builder builder) {
306      this.parent = builder.parent;
307      this.beanStore = builder.beanStore;
308      this.servletConfig = builder.servletConfig;
309      var initial = new LinkedHashMap<String,RestContext>();
310      for (var rc : builder.list)
311         initial.put(rc.getPath(), rc);
312      this.children = u(initial);
313      var lazyMap = new LinkedHashMap<String,LazyChildEntry>();
314      for (var e : builder.lazyList)
315         lazyMap.put(e.path, e);
316      this.lazyEntries = u(lazyMap);
317   }
318
319   /**
320    * Returns the children in this object as a map.
321    *
322    * <p>
323    * The keys are the {@link RestContext#getPath() paths} of the child contexts. The returned map is an
324    * unmodifiable snapshot taken at the time of the call; subsequent {@link #addChild(Class) addChild} /
325    * {@link #removeChild(String) removeChild} calls do not affect this view.
326    *
327    * @return The children as an unmodifiable map.
328    */
329   public Map<String,RestContext> asMap() {
330      return children;
331   }
332
333   /**
334    * Called during servlet destruction on all children to invoke all {@link RestDestroy} and {@link Servlet#destroy()} methods.
335    *
336    * <p>
337    * Lazy children that were never materialized are silently skipped (no context was ever built for them).
338    */
339   public void destroy() {
340      for (var r : children.values())
341         destroyChild(r);
342      for (var e : lazyEntries.values()) {
343         var rc = e.materialized;  // volatile read — safe without lock
344         if (rc != null)
345            destroyChild(rc);
346      }
347   }
348
349   /**
350    * Looks through the registered children of this object and returns the best match.
351    *
352    * <p>
353    * Eager children are checked first (lock-free volatile read), then lazy entries.  A matching lazy entry
354    * triggers {@link LazyChildEntry#materialize()} which builds the full {@link RestContext} on first call
355    * (blocking concurrent first-requests until construction completes) and caches the result.
356    *
357    * @param builder The HTTP call builder. Must not be <jk>null</jk>.
358    * @return The child that best matches the call, or an empty {@link Optional} if a match could not be made.
359    * @throws ServletException If a lazy child fails to materialize.
360    */
361   public Optional<RestChildMatch> findMatch(RestSession.Builder builder) throws ServletException {
362      var pi = builder.getPathInfoUndecoded();
363      if (nn(pi) && neq(pi, "/")) {
364         // Check eager children first.
365         var snapshot = children;  // single volatile read
366         for (var rc : snapshot.values()) {
367            var pm = rc.getPathMatcher();  // Treated as nullable per RestContext's own usage (see RestContext.getPathMatcher() null-guards).
368            UrlPathMatch uppm = pm == null ? null : pm.match(builder.getUrlPath());
369            if (nn(uppm))
370               return o(RestChildMatch.create(uppm, rc));
371         }
372         // Check lazy entries.
373         for (var e : lazyEntries.values()) {
374            UrlPathMatch uppm = e.pathMatcher.match(builder.getUrlPath());
375            if (nn(uppm)) {
376               var rc = e.materialize();
377               return o(RestChildMatch.create(uppm, rc));
378            }
379         }
380      }
381      return oe();
382   }
383
384   /**
385    * Called during servlet initialization on all eager children to invoke all {@link RestPostInit} child-last methods.
386    *
387    * <p>
388    * Lazy children are intentionally excluded — they are initialized on first invocation, not at startup.
389    *
390    * @throws ServletException Error occurred.
391    */
392   public void postInit() throws ServletException {
393      for (var childContext : children.values())
394         childContext.postInit();
395   }
396
397   /**
398    * Called during servlet initialization on all eager children to invoke all {@link RestPostInit} child-first methods.
399    *
400    * <p>
401    * Lazy children are intentionally excluded — they are initialized on first invocation, not at startup.
402    *
403    * @throws ServletException Error occurred.
404    */
405   public void postInitChildFirst() throws ServletException {
406      for (var childContext : children.values())
407         childContext.postInitChildFirst();
408   }
409
410   /**
411    * Returns the lazy child entries registered on this object.
412    *
413    * <p>
414    * Each entry carries the path prefix and materialization state. Primarily used for testing.
415    *
416    * @return An unmodifiable map of path-to-lazy-entry. Never {@code null}.
417    */
418   public Map<String,LazyChildEntry> getLazyEntries() {
419      return lazyEntries;
420   }
421
422   //-------------------------------------------------------------------------------------------------------------
423   // Dynamic add/remove API.
424   //
425   // Routing reads (findMatch / asMap) are lock-free against the volatile `children` snapshot.
426   // Mutations synchronize on writeLock and atomically swap in a new unmodifiable LinkedHashMap.
427   //-------------------------------------------------------------------------------------------------------------
428
429   /**
430    * Dynamically registers a child REST resource by class, instantiating it via the parent's {@link BeanStore}.
431    *
432    * <p>
433    * The path under which the child is registered is determined by the {@link Rest#path() @Rest(path)} annotation
434    * on the resource class composed against the parent's full path.
435    *
436    * <p>
437    * The new child's {@link RestContext#postInit()} and {@link RestContext#postInitChildFirst()} lifecycle hooks
438    * are invoked before this method returns, mirroring the eager-init behavior of {@code @Rest(children = ...)}.
439    *
440    * @param resourceClass The {@code @Rest}-annotated resource class. Must not be <jk>null</jk>.
441    * @return The newly-built child {@link RestContext}.
442    * @throws ServletException If construction or lifecycle initialization of the child fails.
443    * @throws IllegalStateException If a child is already registered at the resolved path.
444    */
445   public RestContext addChild(Class<?> resourceClass) throws ServletException {
446      return addChildInternal(resourceClass, null, "", false);
447   }
448
449   /**
450    * Dynamically registers a pre-instantiated child REST resource.
451    *
452    * <p>
453    * The path under which the child is registered is determined by the {@link Rest#path() @Rest(path)} annotation
454    * on the resource's class composed against the parent's full path.
455    *
456    * @param resource The {@code @Rest}-annotated resource instance. Must not be <jk>null</jk>.
457    * @return The newly-built child {@link RestContext}.
458    * @throws ServletException If construction or lifecycle initialization of the child fails.
459    * @throws IllegalStateException If a child is already registered at the resolved path.
460    */
461   public RestContext addChild(Object resource) throws ServletException {
462      return addChildInternal(resource.getClass(), resource, "", false);
463   }
464
465   /**
466    * Dynamically registers a pre-instantiated child REST resource, optionally replacing any existing child at the
467    * same resolved path.
468    *
469    * @param resource The {@code @Rest}-annotated resource instance. Must not be <jk>null</jk>.
470    * @param replace If {@code true}, an existing child at the same path is destroyed and removed before the new
471    *    child is added. If {@code false}, an existing child causes an {@link IllegalStateException}.
472    * @return The newly-built child {@link RestContext}.
473    * @throws ServletException If construction or lifecycle initialization of the child fails.
474    */
475   public RestContext addChild(Object resource, boolean replace) throws ServletException {
476      return addChildInternal(resource.getClass(), resource, "", replace);
477   }
478
479   /**
480    * Dynamically registers a pre-instantiated child REST resource at an explicit path.
481    *
482    * <p>
483    * The supplied {@code path} overrides whatever {@link Rest#path() @Rest(path)} would normally provide. Useful
484    * for mounting the same servlet class at multiple paths, or for test fixtures that compose paths programmatically.
485    *
486    * @param path The path segment under the parent at which to mount this child. Leading slashes are trimmed.
487    * @param resource The {@code @Rest}-annotated resource instance. Must not be <jk>null</jk>.
488    * @return The newly-built child {@link RestContext}.
489    * @throws ServletException If construction or lifecycle initialization of the child fails.
490    * @throws IllegalStateException If a child is already registered at the resolved path.
491    */
492   public RestContext addChild(String path, Object resource) throws ServletException {
493      return addChildInternal(resource.getClass(), resource, path, false);
494   }
495
496   /**
497    * Dynamically registers a pre-instantiated child REST resource at an explicit path, optionally replacing any
498    * existing child at the same path.
499    *
500    * @param path The path segment under the parent at which to mount this child. Leading slashes are trimmed.
501    * @param resource The {@code @Rest}-annotated resource instance. Must not be <jk>null</jk>.
502    * @param replace If {@code true}, an existing child at the same path is destroyed and removed before the new
503    *    child is added. If {@code false}, an existing child causes an {@link IllegalStateException}.
504    * @return The newly-built child {@link RestContext}.
505    * @throws ServletException If construction or lifecycle initialization of the child fails.
506    */
507   public RestContext addChild(String path, Object resource, boolean replace) throws ServletException {
508      return addChildInternal(resource.getClass(), resource, path, replace);
509   }
510
511   /**
512    * Removes the child registered at the given composed path.
513    *
514    * <p>
515    * The removed child has {@link RestContext#destroy()} invoked (which runs {@code @RestDestroy} hooks and
516    * recursively destroys any grandchildren), followed by {@link Servlet#destroy()} if the underlying resource is
517    * a {@link Servlet}.
518    *
519    * @param path The composed path key (as returned by {@link RestContext#getPath()}) of the child to remove.
520    * @return The removed and destroyed {@link RestContext}, or {@code null} if no child was registered at the
521    *    given path.
522    */
523   public RestContext removeChild(String path) {
524      RestContext removed;
525      synchronized (writeLock) {
526         removed = children.get(path);
527         if (removed == null)
528            return null;
529         var next = new LinkedHashMap<>(children);
530         next.remove(path);
531         children = u(next);
532      }
533      destroyChild(removed);
534      return removed;
535   }
536
537   /**
538    * Removes the first child whose resource class matches the given type.
539    *
540    * @param resourceClass The resource class to match. Matching is performed via {@link RestContext#getResourceClass()}.
541    * @return The removed and destroyed {@link RestContext}, or {@code null} if no matching child was found.
542    */
543   public RestContext removeChild(Class<?> resourceClass) {
544      String key = null;
545      RestContext removed = null;
546      synchronized (writeLock) {
547         for (var e : children.entrySet()) {
548            if (e.getValue().getResourceClass() == resourceClass) {
549               key = e.getKey();
550               removed = e.getValue();
551               break;
552            }
553         }
554         if (removed == null)
555            return null;
556         var next = new LinkedHashMap<>(children);
557         next.remove(key);
558         children = u(next);
559      }
560      destroyChild(removed);
561      return removed;
562   }
563
564   //-------------------------------------------------------------------------------------------------------------
565   // Internal helpers.
566   //-------------------------------------------------------------------------------------------------------------
567
568   /**
569    * Shared child-context construction recipe — used both by the eager init memoizer in {@link RestContext} and by
570    * the runtime {@link #addChild(Class) addChild} family of methods.
571    *
572    * <p>
573    * Resolves a {@link Supplier} for the resource (prefers an existing bean of the same class in the bean store,
574    * otherwise instantiates via {@link BeanInstantiator}), constructs the child {@link RestContext} using the
575    * supplied {@code parent} / {@code servletConfig} / {@code pathOverride}, and invokes any
576    * {@code setContext(RestContext)} method on the resource via reflection (so that {@code RestServlet}-style
577    * resources receive their context handle).
578    *
579    * @param parent The parent {@link RestContext}. Must not be {@code null}.
580    * @param beanStore The bean store to resolve instances and dependencies from. Must not be {@code null}.
581    * @param servletConfig The {@link ServletConfig} to pass through to the child. May be {@code null}.
582    * @param resolvedChild The normalized child declaration (class plus any host-declared {@code @Child} seed).
583    *    Must not be {@code null}.
584    * @param resourceInstance A pre-supplied instance of the resource. If {@code null}, the instance is resolved
585    *    from the bean store or freshly instantiated via {@link BeanInstantiator}.
586    * @param pathOverride An explicit path segment (relative to the parent). Use {@code ""} to read the path from
587    *    {@link Rest#path() @Rest(path)} on the resource class.
588    * @return The newly-built child {@link RestContext}, with its {@code setContext} method (if any) invoked.
589    * @throws Exception If construction or reflective wiring fails.
590    */
591   static RestContext buildChildContext(
592         RestContext parent,
593         BeanStore beanStore,
594         ServletConfig servletConfig,
595         RestContext.ResolvedChild resolvedChild,
596         Object resourceInstance,
597         String pathOverride) throws Exception {
598      var resourceClass = resolvedChild.type();
599      Supplier<?> so;
600      if (resourceInstance != null) {
601         final Object r = resourceInstance;
602         so = () -> r;
603      } else if (beanStore.getBean(resourceClass).isPresent()) {
604         so = () -> beanStore.getBean(resourceClass).get();
605      } else {
606         Object o = BeanInstantiator.of(resourceClass, beanStore).run();
607         so = () -> o;
608      }
609      var cc = new RestContext(new RestContext.Args(resourceClass, parent, servletConfig, so, pathOverride, null, null, null, new RestContext.ContextKind.Child(resolvedChild)));
610      var mi = ClassInfo.of(so.get())
611         .getMethod(x -> x.hasName("setContext") && x.hasParameterTypes(RestContext.class))
612         .orElse(null);
613      if (nn(mi))
614         mi.accessible().invoke(so.get(), cc);
615      return cc;
616   }
617
618   private RestContext addChildInternal(Class<?> resourceClass, Object resourceInstance, String pathOverride, boolean replace) throws ServletException {
619      if (parent == null || beanStore == null)
620         throw isex("Cannot add a child to a RestChildren that was not initialized with a parent RestContext.");
621      RestContext cc;
622      try {
623         // The dynamic-add API has no seed — a bare ResolvedChild preserves its existing no-override behavior.
624         cc = buildChildContext(parent, beanStore, servletConfig, RestContext.ResolvedChild.ofBare(resourceClass), resourceInstance, pathOverride);
625      } catch (Exception e) {
626         throw new ServletException("Failed to build child REST context for " + resourceClass.getName(), unwrapThrowable(e));
627      }
628      var key = cc.getPath();
629      RestContext replaced = null;
630      synchronized (writeLock) {
631         if (children.containsKey(key)) {
632            if (! replace) {
633               destroyQuietly(cc);
634               throw isex("Child resource already registered at path '%s'.", key);
635            }
636            replaced = children.get(key);
637            var withoutExisting = new LinkedHashMap<>(children);
638            withoutExisting.remove(key);
639            // We don't publish the intermediate "without existing" snapshot — single atomic swap below.
640            withoutExisting.put(key, cc);
641            children = u(withoutExisting);
642         } else {
643            var next = new LinkedHashMap<>(children);
644            next.put(key, cc);
645            children = u(next);
646         }
647      }
648      if (replaced != null)
649         destroyChild(replaced);
650      cc.postInit();
651      cc.postInitChildFirst();
652      return cc;
653   }
654
655   private static void destroyChild(RestContext child) {
656      // Servlet-backed resources delegate Servlet.destroy() through to RestContext.destroy() (see RestServlet.destroy),
657      // so we must avoid double-destruction in that case — call Servlet.destroy() and let it transitively call
658      // RestContext.destroy() exactly once.
659      Object resource;
660      try {
661         resource = child.getResource();
662      } catch (NoSuchElementException e) {
663         // The resource bean was already removed by the owning container (e.g. a closed Spring context),
664         // so there is no instance left to destroy.
665         return;
666      }
667      if (resource instanceof Servlet s)
668         s.destroy();
669      else
670         child.destroy();
671   }
672
673   private static void destroyQuietly(RestContext child) {
674      try {
675         destroyChild(child);
676      } catch (Exception ignored) {
677         // best-effort cleanup of a child we're about to throw away
678      }
679   }
680
681   private static Throwable unwrapThrowable(Throwable t) {
682      if (t instanceof InvocationTargetException t2)
683         return t2.getTargetException();
684      return t;
685   }
686}