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.microservice.jetty;
018
019import static org.apache.juneau.commons.reflect.ReflectionUtils.*;
020import static org.apache.juneau.commons.utils.IoUtils.*;
021import static org.apache.juneau.commons.utils.ObjectUtils.*;
022import static org.apache.juneau.commons.utils.Shorts.*;
023import static org.apache.juneau.commons.utils.StringUtils.*;
024import static org.apache.juneau.commons.utils.ThrowableUtils.*;
025import static org.apache.juneau.marshall.collections.JsonMap.*;
026
027import java.io.*;
028import java.net.*;
029import java.nio.file.*;
030import java.time.*;
031import java.util.*;
032import java.util.concurrent.atomic.*;
033import java.util.logging.*;
034
035import org.apache.juneau.commons.inject.*;
036import org.apache.juneau.commons.reflect.*;
037import org.apache.juneau.config.event.*;
038import org.apache.juneau.marshall.cp.*;
039import org.apache.juneau.microservice.*;
040import org.apache.juneau.rest.server.*;
041import org.apache.juneau.rest.server.health.*;
042import org.apache.juneau.rest.server.servlet.*;
043import org.eclipse.jetty.ee11.servlet.*;
044import org.eclipse.jetty.server.*;
045
046import jakarta.servlet.*;
047
048/**
049 * Drives the Jetty server lifecycle on behalf of the microservice via the {@link MicroserviceListener} fan-out.
050 *
051 * <p>
052 * Contributed by {@link JettyConfiguration} as a <c>@Bean</c>.  The microservice's {@link Microservice#start()}
053 * call invokes {@link #onStart(Microservice)}, which:
054 * <ol>
055 *    <li>Picks the first available port from the resolved {@link JettySettings#getPorts() ports} (falling back to
056 *       <c>Jetty/port</c> config, then <c>Jetty-Port</c> manifest, then <c>8000</c>).
057 *    <li>Resolves the Jetty {@link Server} — preferring an externally-supplied <c>@Bean Server</c>, otherwise
058 *       building one from <c>jetty.xml</c> via the {@link JettyServerFactory}.
059 *    <li>Mounts servlets from {@code Jetty/servlets}, {@code Jetty/servletMap}, {@code Jetty/servletAttributes}
060 *       config sections, plus auto-mounts every {@code @Bean Servlet} whose runtime class is annotated with
061 *       {@link Rest @Rest} (at {@link Rest#path()}, or <c>"/"</c> when no path is set).
062 *    <li>Mounts every <c>@Bean</c> {@link ServletFilterRegistration} (and the <c>@Bean</c> <c>AuthFilterChain</c>, at
063 *       <c>/*</c>, unless a registration already wraps it) in {@link ServletFilterRegistration#getOrder() order}.
064 *    <li>Starts the server and publishes the bound port via the {@code availablePort} / {@code juneau.serverPort}
065 *       system properties.
066 * </ol>
067 *
068 * <p>
069 * {@link #onStop(Microservice)} performs the inverse: stops the server gracefully in a worker thread.
070 *
071 * <p>
072 * External callers reach this component via
073 * {@code Microservice.getInstance().getBeanStore().getBean(JettyServerComponent.class).orElseThrow()}.
074 *
075 * <h5 class='section'>See Also:</h5><ul>
076 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/JuneauMicroserviceJetty">juneau-microservice-jetty</a>
077 * </ul>
078 *
079 * @since 10.0.0
080 */
081@SuppressWarnings({
082   "java:S115", // Constants use UPPER_snakeCase convention (e.g., KEY_SERVLET_CONTEXT_HANDLER)
083   "java:S3878", // Array wrapper required to dispatch to varargs overload; without it the call recurses infinitely back to this single-String overload.
084   "resource" // ms.getBeanStore() is owned by the microservice lifecycle; do not close here.
085})
086public class JettyServerComponent implements MicroserviceListener {
087
088   private static final String KEY_SERVLET_CONTEXT_HANDLER = "ServletContextHandler";
089
090   @SuppressWarnings({
091      "java:S2245" // Used only to pick a benign ephemeral listen port (non security-sensitive); SecureRandom is unnecessary.
092   })
093   private static final Random RANDOM = new Random();
094
095   /** Default bounded graceful-shutdown drain timeout applied to the Jetty server when none is configured. */
096   static final Duration DEFAULT_STOP_TIMEOUT = Duration.ofSeconds(30);
097
098   private final Messages messages = Messages.of(JettyServerComponent.class);
099   private final AtomicReference<Server> server = new AtomicReference<>();
100   private final AtomicReference<Microservice> microservice = new AtomicReference<>();
101   private final AtomicReference<Duration> shutdownSettleDelay = new AtomicReference<>(Duration.ZERO);
102
103   /**
104    * Env-driven sentinel for {@code availablePort}; {@link Optional#empty()} when unset (in which case
105    * {@link #onStart(Microservice)} publishes the bound port back as the {@code availablePort} system property).
106    */
107   @Value("${availablePort}")
108   Optional<String> availablePortEnv = oe();
109
110   /**
111    * Env-driven sentinel for {@code juneau.serverPort}; {@link Optional#empty()} when unset (in which case
112    * {@link #onStart(Microservice)} publishes the bound port back as the {@code juneau.serverPort} system property).
113    */
114   @Value("${juneau.serverPort}")
115   Optional<String> serverPortEnv = oe();
116
117   /**
118    * Creates the directory the bundled <c>jetty.xml</c>'s request log writes into, so a fresh working directory starts.
119    *
120    * <p>
121    * A failure is logged rather than thrown; Jetty reports the unusable directory when the request log starts.
122    *
123    * @param dir The log directory (<c>Logging/logDir</c>).
124    * @param logger The logger to report a failure to.
125    */
126   static void createLogDir(String dir, Logger logger) {
127      try {
128         Files.createDirectories(Path.of(dir));
129      } catch (IOException | InvalidPathException e) {
130         logger.warning("Could not create log directory '" + dir + "': " + e);
131      }
132   }
133
134   private static int[] parseIntArray(String csv) {
135      if (ie(csv))
136         return new int[0];
137      var parts = csv.split(",");
138      var out = new int[parts.length];
139      for (var i = 0; i < parts.length; i++)
140         out[i] = Integer.parseInt(parts[i].trim());
141      return out;
142   }
143
144   private static int findOpenPort(int[] ports) {
145      for (var port : ports) {
146         if (port == 0)
147            port = RANDOM.nextInt(32767 - ports[0] + 1) + ports[0];
148         try (var ss = new ServerSocket(port)) {
149            return port;
150         } catch (@SuppressWarnings("unused") IOException e) {
151            // Port is in use, try next port in array
152         }
153      }
154      return 0;
155   }
156
157   private static String normalizePathSpec(String rawPath) {
158      var p = rawPath == null ? "" : rawPath;
159      if (p.isEmpty() || eq(p, "/"))
160         return "/*";
161      if (! p.startsWith("/"))
162         p = "/" + p;
163      if (! p.endsWith("/*"))
164         p = trimTrailingSlashes(p) + "/*";
165      return p;
166   }
167
168   private static String normalizeExactPathSpec(String rawPath) {
169      var p = rawPath == null ? "" : trimTrailingSlashes(rawPath);
170      if (p.isEmpty())
171         return "/";
172      if (! p.startsWith("/"))
173         p = "/" + p;
174      return p;
175   }
176
177   /**
178    * Resolves the top-level Jetty mount path-specs for an auto-discovered {@code @Rest}-annotated servlet.
179    *
180    * <p>
181    * Delegates to {@link RestContext#resolveTopLevelPaths(Class, Object, BeanStore)} so the runtime-override
182    * resolution chain (programmatic &gt; {@code getPaths()} getter &gt; annotation default) is honored at
183    * mount time. The programmatic Builder rung is N/A for Jetty auto-discovery (the {@code RestContext} does
184    * not exist yet at this point in the lifecycle); users can substitute by overriding {@code getPaths()} on
185    * the resource class, or by leaning on SVL inside {@code @Rest(paths=...)} elements (e.g.
186    * {@code paths={"$C{health.paths}"}}) so each element is resolved through {@code Config} /
187    * {@code $E{...}} / {@code $S{...}} before being comma-split.
188    *
189    * @return The normalized exact-match path-specs (for multi-mount), or the singular {@code @Rest(path)}-style
190    *    {@code "/...&#47;*"} pathspec when no {@code paths} are resolved.
191    */
192   private static String[] restPathsFor(Servlet servlet, BeanStore store) {
193      var cls = servlet.getClass();
194      var r = cls.getAnnotation(Rest.class);
195      if (r == null)
196         return new String[] {"/*"};
197      var resolved = RestContext.resolveTopLevelPaths(cls, servlet, store);
198      if (resolved.length > 0)
199         return Arrays.stream(resolved).map(JettyServerComponent::normalizeExactPathSpec).toArray(String[]::new);
200      return new String[] {normalizePathSpec(r.path())};
201   }
202
203   /**
204    * Constructor.
205    */
206   @SuppressWarnings({
207      "java:S1186" // Empty constructor; all state is initialized via onStart(Microservice) when wired through the bean store.
208   })
209   public JettyServerComponent() {}
210
211   @Override /* Overridden from MicroserviceListener */
212   @SuppressWarnings({
213      "java:S1141", // Nested try blocks scope checked-exception translation tightly to single call sites
214      "java:S3776", // Cognitive complexity acceptable for server creation logic
215      "java:S6541" // Brain Method acceptable for server startup wiring logic
216   })
217   public void onStart(Microservice ms) {
218      try {
219         microservice.set(ms);
220         var store = ms.getBeanStore();
221         ClassInfo.of(this).inject(this, store);
222         var cf = ms.getConfig();
223         var mf = ms.getManifest();
224         var vr = ms.getVarResolver();
225         var settings = store.getBean(JettySettings.class).orElseGet(() -> JettySettings.create().build());
226
227         // Port resolution: settings > Jetty/port config > Jetty-Port manifest > 8000
228         var ports = settings.getPorts();
229         if (ports == null)
230            ports = cf.get("Jetty/port").as(int[].class)
231               .orElseGet(() -> mf.get("Jetty-Port").map(JettyServerComponent::parseIntArray).orElseGet(() -> ints(8000)));
232         var availablePort = findOpenPort(ports);
233
234         if (availablePortEnv.isEmpty())
235            // Intentional real-JVM-property publish: the bundled jetty.xml binds its connector to
236            // $S{availablePort,10000}, and the $S{...} SVL var resolves via raw System.getProperty (bypassing
237            // the Settings override chain).  A Settings-based write would be invisible to jetty.xml, so the
238            // bound port MUST be published as a real system property here.  The @Value-injected availablePortEnv
239            // read side is already Settings-abstracted; only this publish must stay raw.
240            System.setProperty("availablePort", String.valueOf(availablePort));
241
242         // Prefer a @Bean-supplied Server, else build one from jetty.xml.
243         var injectedServer = store.getBean(Server.class).orElse(null);
244         if (nn(injectedServer)) {
245            server.set(injectedServer);
246         } else {
247            var factory = store.getBean(JettyServerFactory.class).orElseGet(BasicJettyServerFactory::new);
248            var jettyXml = settings.getJettyXml();
249            var jettyConfig = cf.get("Jetty/config").orElseGet(() -> mf.get("Jetty-Config").orElse("jetty.xml"));
250            var resolveVars = coalesce(settings.getJettyXmlResolveVars(), cf.get("Jetty/resolveVars").asBoolean().orElse(false));
251            boolean resolveVars2 = isTrue(resolveVars);
252
253            if (jettyXml == null)
254               jettyXml = loadSystemResourceAsString(jettyConfig, ".", "files");
255            if (jettyXml == null)
256               throw rex("jetty.xml file '%s' was not found on the file system or classpath.", jettyConfig);
257
258            if (resolveVars2)
259               jettyXml = vr.resolve(jettyXml);
260
261            ms.getLogger().config(jettyXml);
262
263            createLogDir(cf.get("Logging/logDir").orElse("logs"), ms.getLogger());
264
265            try {
266               server.set(factory.create(jettyXml));
267            } catch (Exception e) {
268               throw toRex(e);
269            }
270         }
271
272         // Publish back to the store for downstream beans / lookups.
273         store.addBean(Server.class, server.get());
274
275         // Graceful-shutdown wiring (zero-downtime k8s rollouts):
276         //  - Apply a bounded stopTimeout so server.stop() drains in-flight requests before the connector closes.
277         //    Precedence: JettySettings > Jetty/stopTimeout config > jetty.xml value (if any) > 30s default.
278         //  - Remember the settle delay so onStop() can let the LB observe the /readyz 503 before draining.
279         //  - Mark readiness ready so a freshly-(re)started service serves traffic.
280         var stopTimeoutOverride = settings.getStopTimeout() != null
281            ? settings.getStopTimeout()
282            : cf.get("Jetty/stopTimeout").asLong().map(Duration::ofMillis).orElse(null);
283         if (stopTimeoutOverride != null)
284            server.get().setStopTimeout(Math.max(0L, stopTimeoutOverride.toMillis()));
285         else if (server.get().getStopTimeout() <= 0L)
286            server.get().setStopTimeout(DEFAULT_STOP_TIMEOUT.toMillis());
287         shutdownSettleDelay.set(coalesce(settings.getShutdownSettleDelay(), cf.get("Jetty/shutdownSettleDelay").asLong().map(Duration::ofMillis).orElse(Duration.ZERO)));
288
289         // Per-service readiness: reuse an app/test-supplied @Bean ReadinessState if present, else construct
290         // one, and publish it into this microservice's bean store.  shared() is intentionally NOT used here:
291         // it is one JVM-wide singleton, so falling back to it would leak this service's readiness into every
292         // other default-configured microservice in the same JVM.  The health-probe servlet side of this
293         // dual-store publish happens below, in the @Rest servlet auto-discovery loop.
294         var readinessState = store.getBean(ReadinessState.class).orElseGet(ReadinessState::new);
295         store.addBean(ReadinessState.class, readinessState);
296         readinessState.markReady();
297
298         // Track each servlet pathSpec with its declaring source so we can fail loudly on collisions.
299         var mountedPaths = new LinkedHashMap<String,String>();
300
301         for (var s : cf.get("Jetty/servlets").asStringArray().orElse(new String[0])) {
302            try {
303               var c = info(Class.forName(s));
304               if (c.isAssignableTo(RestServlet.class)) {
305                  var rs = (RestServlet)c.newInstance();
306                  mountWithCollisionCheck(rs, rs.getPath(), "Jetty/servlets[" + s + "]", mountedPaths);
307               } else {
308                  throw rex("Invalid servlet specified in Jetty/servlets.  Must be a subclass of RestServlet: %s", s);
309               }
310            } catch (ClassNotFoundException e) {
311               throw toRex(e);
312            }
313         }
314
315         cf.get("Jetty/servletMap").asMap().orElse(EMPTY_MAP).forEach((k, v) -> {
316            try {
317               var c = info(Class.forName(v.toString()));
318               if (c.isAssignableTo(Servlet.class)) {
319                  var rs = (Servlet)c.newInstance();
320                  mountWithCollisionCheck(rs, k, "Jetty/servletMap[" + k + "]", mountedPaths);
321               } else {
322                  throw rex("Invalid servlet specified in Jetty/servletMap.  Must be a subclass of Servlet: %s", cn(v));
323               }
324            } catch (ClassNotFoundException e) {
325               throw toRex(e);
326            }
327         });
328
329         cf.get("Jetty/servletAttributes").asMap().orElse(EMPTY_MAP).forEach(this::addServletAttribute);
330
331         // Mount the @Bean ServletFilterRegistration filters, plus the @Bean AuthFilterChain at /* unless a
332         // registration already wraps it, outermost first.  Filter matching does not depend on whether the
333         // servlets or the filters were added first, so mounting after the config-sourced servlets is fine.
334         for (var registration : ServletFilterRegistration.collect(store))
335            addFilter(registration);
336
337         // Auto-discover @Rest servlets contributed via @Configuration/@Bean methods.
338         // Path source precedence: @Rest(path=...) on the resource class, else "/".
339         for (var e : store.getBeansOfType(Servlet.class).entrySet()) {
340            var servlet = e.getValue();
341            var cls = servlet.getClass();
342            if (cls.getAnnotation(Rest.class) == null)
343               continue;
344            if (servlet instanceof HealthServlet hs)
345               // Dual-store publish (READY-394): this probe's own RestContext bean store is not linked to
346               // ms.getBeanStore(), so the lifecycle-owned instance must be handed to it explicitly before
347               // it initializes; HealthServlet.initReadinessState() registers it into its own bean store.
348               hs.publishReadinessState(readinessState);
349            if (servlet instanceof ReadinessStateAware rsa)
350               // Lets servlets holding long-lived streams (e.g. MCP subscriptions/listen) end them when shutdown
351               // begins, so the graceful drain does not wait out the full stopTimeout for them.
352               rsa.acceptReadinessState(readinessState);
353            var pathSpecs = restPathsFor(servlet, store);
354            var source = "@Bean " + cls.getName() + (ine(e.getKey()) ? "[" + e.getKey() + "]" : "");
355            for (var pathSpec : pathSpecs)
356               checkPathCollision(pathSpec, source, mountedPaths);
357            addServlet(servlet, pathSpecs);
358         }
359
360         if (serverPortEnv.isEmpty())
361            // Intentional real-JVM-property publish: juneau.serverPort is a documented cross-component/external
362            // publication of the bound port, consumed via raw System.getProperty (and $S{...} SVL).  A
363            // Settings-based write would not be visible to those raw readers, so this is kept.  The
364            // @Value-injected serverPortEnv read side is already Settings-abstracted.
365            System.setProperty("juneau.serverPort", String.valueOf(availablePort));
366
367         server.get().start();
368         ms.out(messages, "ServerStarted", getPort());
369      } catch (Exception e) {
370         throw toRex(e);
371      }
372   }
373
374   @Override /* Overridden from MicroserviceListener */
375   public void onStop(Microservice ms) {
376      final Logger logger = ms.getLogger();
377      // Flip readiness out of service BEFORE the connector stops so /readyz returns 503 and the load balancer /
378      // Kubernetes stops routing new traffic while in-flight requests drain.  /livez stays healthy so the pod is
379      // not killed mid-drain.  The settle delay (default 0) gives the LB a window to observe the 503.
380      ReadinessState.resolve(ms.getBeanStore()).markOutOfService();
381      ms.out(messages, "DrainingRequests");
382      sleepQuietly(shutdownSettleDelay.get());
383      var t = new Thread("JettyServerComponentStop") {
384         @Override /* Overridden from Thread */
385         public void run() {
386            try {
387               var s = server.get();
388               if (s == null || s.isStopping() || s.isStopped())
389                  return;
390               ms.out(messages, "StoppingServer");
391               s.stop();
392               ms.out(messages, "ServerStopped");
393            } catch (Exception e) {
394               logger.log(Level.WARNING, localizedMessage(e), e);
395            }
396         }
397      };
398      t.start();
399      try {
400         t.join();
401      } catch (@SuppressWarnings("unused") InterruptedException e) {
402         Thread.currentThread().interrupt();
403      }
404   }
405
406   private static void sleepQuietly(Duration d) {
407      if (d == null || d.isZero() || d.isNegative())
408         return;
409      try {
410         Thread.sleep(d.toMillis());
411      } catch (@SuppressWarnings("unused") InterruptedException e) {
412         Thread.currentThread().interrupt();
413      }
414   }
415
416   @Override /* Overridden from MicroserviceListener */
417   public void onConfigChange(Microservice ms, ConfigEvents events) {
418      // No-op: Intentional empty implementation - this component is purely lifecycle-driven.
419   }
420
421   /**
422    * Adds an arbitrary servlet to this Jetty server.
423    *
424    * @param servlet The servlet instance.
425    * @param pathSpec The context path of the servlet.
426    * @return This object.
427    */
428   public JettyServerComponent addServlet(Servlet servlet, String pathSpec) {
429      if (nn(pathSpec) && ! pathSpec.endsWith("/*"))
430         pathSpec = trimTrailingSlashes(pathSpec) + "/*";
431      return addServlet(servlet, new String[]{pathSpec});
432   }
433
434   /**
435    * Adds an arbitrary servlet to this Jetty server at one or more context paths.
436    *
437    * @param servlet The servlet instance.
438    * @param pathSpecs The context paths of the servlet.
439    * @return This object.
440    */
441   public JettyServerComponent addServlet(Servlet servlet, String...pathSpecs) {
442      var sh = new ServletHolder(servlet);
443      for (var pathSpec : pathSpecs)
444         getServletContextHandler().addServlet(sh, pathSpec);
445      return this;
446   }
447
448   /**
449    * Adds a servlet filter to this Jetty server at the specified URL pattern.
450    *
451    * @param filter The filter instance.
452    * @param urlPattern The URL pattern the filter applies to (e.g. {@code "/*"}, {@code "/api/*"}).
453    * @return This object.
454    */
455   public JettyServerComponent addFilter(jakarta.servlet.Filter filter, String urlPattern) {
456      return addFilter(filter, new String[]{urlPattern});
457   }
458
459   /**
460    * Adds a servlet filter to this Jetty server at one or more URL patterns.
461    *
462    * @param filter The filter instance.
463    * @param urlPatterns The URL patterns the filter applies to.
464    * @return This object.
465    */
466   public JettyServerComponent addFilter(jakarta.servlet.Filter filter, String... urlPatterns) {
467      var fh = new FilterHolder(filter);
468      for (var urlPattern : urlPatterns)
469         getServletContextHandler().addFilter(fh, urlPattern, EnumSet.of(DispatcherType.REQUEST));
470      return this;
471   }
472
473   /**
474    * Adds a servlet filter registration to this Jetty server.
475    *
476    * <p>
477    * Maps the filter on every URL pattern and dispatcher type of the registration with one mapping, so the filter runs at
478    * most once per dispatch even when the patterns overlap.  Mapping order is call order, so call this outermost first.
479    *
480    * <p>
481    * Applications contribute a {@link ServletFilterRegistration} <c>@Bean</c> instead; {@link #onStart(Microservice)}
482    * applies the beans through this method in {@link ServletFilterRegistration#getOrder() order}.  Calling it directly
483    * is only possible once the server object exists (during startup) and before the server runs.
484    *
485    * @param registration The registration.  Cannot be <jk>null</jk>.
486    * @return This object.
487    * @throws IllegalArgumentException If the registration is <jk>null</jk>.
488    * @throws IllegalStateException If the server has already started.
489    */
490   public JettyServerComponent addFilter(ServletFilterRegistration registration) {
491      if (registration == null)
492         throw iaex("registration must not be null.");
493      var jetty = getServer();
494      if (jetty.isRunning() || jetty.isStopping())
495         throw isex("Filters must be added before the server starts.");
496      var filter = registration.getFilter();
497      var name = registration.getName().orElseGet(() -> cn(filter) + "#" + UUID.randomUUID());
498      var holder = new FilterHolder(filter);
499      holder.setName(name);
500      holder.setAsyncSupported(registration.isAsyncSupported());
501      var mapping = new FilterMapping();
502      mapping.setFilterName(name);
503      mapping.setPathSpecs(registration.getUrlPatterns().toArray(new String[0]));
504      mapping.setDispatcherTypes(EnumSet.copyOf(registration.getDispatcherTypes()));
505      getServletContextHandler().getServletHandler().addFilter(holder, mapping);
506      return this;
507   }
508
509   /**
510    * Adds a servlet filter to this Jetty server on the given dispatcher types and URL patterns.
511    *
512    * <p>
513    * Shorthand for {@link #addFilter(ServletFilterRegistration)} with the default order and async support, and the same
514    * validation.  The overloads without dispatcher types keep their {@code REQUEST}-only behavior.
515    *
516    * @param filter The filter instance.  Cannot be <jk>null</jk>.
517    * @param dispatcherTypes The dispatcher types.  Cannot be empty.
518    * @param urlPatterns The URL patterns.  At least one is required; see {@link org.apache.juneau.microservice.ServletFilterRegistration.Builder#urlPatterns(String...) ServletFilterRegistration.Builder#urlPatterns(String...)}.
519    * @return This object.
520    * @throws IllegalArgumentException If an argument is invalid.
521    * @throws IllegalStateException If the server has already started.
522    */
523   public JettyServerComponent addFilter(jakarta.servlet.Filter filter, Set<DispatcherType> dispatcherTypes, String... urlPatterns) {
524      if (dispatcherTypes == null)
525         throw iaex("dispatcherTypes must not be null.");
526      return addFilter(ServletFilterRegistration.create(filter).urlPatterns(urlPatterns).dispatcherTypes(dispatcherTypes.toArray(DispatcherType[]::new)).build());
527   }
528
529   /**
530    * Adds an attribute to the Jetty server.
531    *
532    * @param name The server attribute name.
533    * @param value The attribute value.
534    * @return This object.
535    */
536   public JettyServerComponent addServletAttribute(String name, Object value) {
537      getServer().setAttribute(name, value);
538      return this;
539   }
540
541   /**
542    * Returns the context path that this server is using.
543    *
544    * @return The context path.
545    */
546   public String getContextPath() {
547      return getServletContextHandler().getContextPath();
548   }
549
550   /**
551    * Returns the hostname of this microservice.
552    *
553    * @return The hostname.
554    */
555   public String getHostName() {
556      String hostname = "localhost";
557      try {
558         hostname = InetAddress.getLocalHost().getHostName();
559      } catch (@SuppressWarnings("unused") UnknownHostException e) {
560         // Cannot determine hostname, use default "localhost"
561      }
562      return hostname;
563   }
564
565   /**
566    * Returns the port that this server is listening on.
567    *
568    * @return The port.
569    */
570   public int getPort() {
571      for (var c : getServer().getConnectors())
572         if (c instanceof ServerConnector c2)
573            return c2.getPort();
574      throw new IllegalStateException("Could not locate ServerConnector in Jetty server.");
575   }
576
577   /**
578    * Returns whether this server is using <js>"http"</js> or <js>"https"</js>.
579    *
580    * @return The protocol.
581    */
582   public String getProtocol() {
583      for (var c : getServer().getConnectors())
584         if (c instanceof ServerConnector c2)
585            for (var cf : c2.getConnectionFactories())
586               if (cf instanceof SslConnectionFactory)
587                  return "https";
588      return "http";
589   }
590
591   /**
592    * Returns the underlying Jetty server.
593    *
594    * @return The underlying Jetty server.
595    * @throws NullPointerException If the server has not been created yet.
596    */
597   public Server getServer() {
598      return Objects.requireNonNull(server.get(), "Server not found.  Microservice has not been started yet.");
599   }
600
601   /**
602    * Finds and returns the servlet context handler defined in the Jetty container.
603    *
604    * @return The servlet context handler.
605    * @throws IllegalStateException If the context handler is not defined.
606    */
607   public ServletContextHandler getServletContextHandler() {
608      var obj = getServer().getAttribute(KEY_SERVLET_CONTEXT_HANDLER);
609      if (obj instanceof ServletContextHandler obj2)
610         return obj2;
611      throw isex("Servlet context handler not found in jetty server or at attribute '%s'", KEY_SERVLET_CONTEXT_HANDLER);
612   }
613
614   /**
615    * Returns the URI where this server is listening.
616    *
617    * @return The URI.
618    */
619   public URI getURI() {
620      var cp = getContextPath();
621      try {
622         return new URI(getProtocol(), null, getHostName(), getPort(), eq(cp, "/") ? null : cp, null, null);
623      } catch (URISyntaxException e) {
624         throw toRex(e);
625      }
626   }
627
628   private void mountWithCollisionCheck(Servlet servlet, String rawPath, String source, Map<String,String> mountedPaths) {
629      var pathSpec = normalizePathSpec(rawPath);
630      checkPathCollision(pathSpec, source, mountedPaths);
631      addServlet(servlet, pathSpec);
632   }
633
634   private void checkPathCollision(String pathSpec, String source, Map<String,String> mountedPaths) {
635      var prior = mountedPaths.get(pathSpec);
636      if (nn(prior))
637         throw rex("Servlet mount path collision: '%s' is already mounted by %s; refused by %s.", pathSpec, prior, source);
638      mountedPaths.put(pathSpec, source);
639   }
640}