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.ops;
018
019import java.lang.annotation.*;
020import java.lang.reflect.Method;
021import java.util.*;
022
023import org.apache.juneau.rest.server.*;
024import org.apache.juneau.rest.server.beans.*;
025
026/**
027 * Mixin that exposes a content-negotiated index of all {@link RestOp @RestOp}-annotated methods on the
028 * host resource (and any other mixins on it) at {@code /options} (configurable).
029 *
030 * <p>
031 * The op returns a {@link RouteDescriptions} POJO rather than writing a fixed format, so Juneau's content
032 * negotiation serves a browsable HTML page (with clickable route links), JSON, or XML based on the request
033 * {@code Accept} header &mdash; mirroring the way {@code NavigationMixin.getChildren(...)} produces a
034 * navigation page while still serving JSON to API clients. The host therefore needs serializers configured
035 * (e.g. via {@code BasicUniversalConfig} / a {@code Basic*} servlet base); a bare {@code @Rest} host with no
036 * serializers cannot content-negotiate the listing.
037 *
038 * <p>
039 * Sibling of {@link EchoMixin} ({@code /echo/*}) and {@link AdminMixin}
040 * ({@code /admin/*}). All three classes live in the {@code org.apache.juneau.rest.server.ops}
041 * ops/introspection mixin pack.
042 *
043 * <h5 class='section'>Configurable mount path:</h5>
044 *
045 * <p>
046 * The default mount {@code /options} can be overridden via the SVL variable
047 * {@code ${juneau.routeindex.path:options}} &mdash; set via system property
048 * ({@code -Djuneau.routeindex.path=routes}), environment variable
049 * ({@code JUNEAU_ROUTEINDEX_PATH=routes}), or {@code Config} key
050 * ({@code juneau.routeindex.path = routes}) to change the runtime mount without subclassing.
051 * Resolution happens once at {@link RestContext} construction time (SVL resolution in {@code @RestOp(path)}).
052 *
053 * <p>
054 * Override accepts bare token ({@code options}), leading slash ({@code /options}), or trailing
055 * slash ({@code options/}) &mdash; all resolve to the same mount.
056 *
057 * <p>
058 * <b>Migration note (10.0.0):</b> Earlier development snapshots of this mixin mounted at both
059 * {@code /options} <i>and</i> {@code /routes} as historical aliases on a single op. That dual
060 * default has been collapsed to a single SVL-configurable mount as part of the
061 * "single path per op" principle. Deployers who relied on the
062 * {@code /routes} alias must now set {@code -Djuneau.routeindex.path=routes} or compose a
063 * second instance with the override.
064 *
065 * <h5 class='section'>Mixin-only deployment:</h5>
066 *
067 * <p>
068 * This resource is designed for composition via {@code @Rest(mixins=...)}. The mount path is
069 * pinned at the op level by {@link RestGet @RestGet(path="/&#123;juneau.routeindex.path:options&#125;")}
070 * on {@link #getRoutes}; a class-level {@code @Rest(paths=...)} declaration would be silently
071 * ignored under the mixin pattern (see {@link Rest#paths() @Rest(paths)} Javadoc). Note also
072 * that the route-index walks the host's {@link RestContext} via {@link #resolveHostContext}
073 * &mdash; it only makes sense in a host-attached composition, not standalone.
074 *
075 * <h5 class='figure'>Composition example:</h5>
076 *
077 * <p class='bjava'>
078 *    <ja>@Rest</ja>(path=<js>"/api"</js>, mixins=RouteIndexMixin.<jk>class</jk>)
079 *    <jk>public class</jk> ApiResource <jk>extends</jk> RestServlet {
080 *       <ja>@RestGet</ja>(path=<js>"/items"</js>, summary=<js>"List items"</js>) <jk>public</jk> List&lt;Item&gt; items() { ... }
081 *    }
082 * </p>
083 *
084 * <h5 class='section'>Output:</h5>
085 *
086 * <p>
087 * Each {@code @RestOp}-annotated method on the host (and on any other mixins on the host) is
088 * surfaced as a single entry, ordered by path. Rendered as JSON (for an {@code Accept: application/json}
089 * request) the listing looks like:
090 *
091 * <p class='bjson'>
092 *    [
093 *       {
094 *          <jok>"path"</jok>: <jov>"/items"</jov>,
095 *          <jok>"methods"</jok>: [<jov>"GET"</jov>],
096 *          <jok>"summary"</jok>: <jov>"List items"</jov>,
097 *          <jok>"description"</jok>: <jov>""</jov>,
098 *          <jok>"deprecated"</jok>: <jov>false</jov>
099 *       }
100 *    ]
101 * </p>
102 *
103 * <p>
104 * <b>Excluded entries:</b>
105 * <ul class='spaced-list'>
106 *    <li>The route-index endpoint itself (it shouldn't echo its own listing).
107 *    <li>Any operation marked {@link OpSwagger#ignore() @OpSwagger(ignore=true)} &mdash; consistent
108 *       with how those operations are excluded from the OpenAPI spec by
109 *       {@code BasicSwaggerProviderSession}. Convention endpoints (favicon, robots, version, etc.),
110 *       static-files mixin handlers, and the sibling ops-pack endpoints all carry that annotation
111 *       and are therefore omitted from the index, matching the audience separation: api-docs is
112 *       for documented public API; route-index is for the same surface but in machine-readable
113 *       form.
114 *    <li>Lifecycle / filter beans &mdash; only methods with a {@link RestOp}-group annotation
115 *       (GET / POST / PUT / DELETE / PATCH / OPTIONS / RestOp) are listed; {@link RestStartCall}
116 *       / {@link RestEndCall} / converters / matchers are not.
117 * </ul>
118 *
119 * <p>
120 * The handler itself carries {@link OpSwagger#ignore() @OpSwagger(ignore=true)} so the
121 * route-index endpoint is excluded from the OpenAPI spec.
122 *
123 * <h5 class='section'>See Also:</h5><ul>
124 *    <li class='jc'>{@link EchoMixin}
125 *    <li class='jc'>{@link AdminMixin}
126 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerComposition">REST Server — Mixins and Multi-Mount Paths</a>
127 * </ul>
128 *
129 * @since 10.0.0
130 */
131// @formatter:off
132@Rest
133@SuppressWarnings({
134   "java:S1192", // Duplicate string literals are REST media type strings and path names; intentional
135   "java:S3776" // isHiddenFromIndex(), readSummary() and readDescription() loop over the REST op annotations with nested try/if branches.
136})
137public class RouteIndexMixin {
138
139   private static final List<Class<? extends Annotation>> REST_OP_ANNOTATIONS = List.of(
140      RestGet.class, RestPost.class, RestPut.class, RestDelete.class,
141      RestPatch.class, RestOptions.class, RestOp.class);
142
143   /** No-arg constructor &mdash; route-index has no configurable state. */
144   public RouteIndexMixin() { /* intentionally empty */ }
145
146   /**
147    * [GET /options] &mdash; return the route index as a content-negotiated POJO.
148    *
149    * <p>
150    * Returns a {@link RouteDescriptions} list rather than writing a fixed format, so the response is
151    * content-negotiated through the host's configured serializers: an {@code Accept: text/html} request
152    * renders a browsable table (with each {@linkplain RouteDescription#getPath() path} as a clickable link),
153    * while {@code application/json} / {@code text/xml} clients receive the same entries in their requested
154    * format &mdash; mirroring the way {@code NavigationMixin.getChildren(...)} backs the child-resource
155    * navigation page.
156    *
157    * @param req The current REST request &mdash; supplies the host {@link RestContext}.
158    * @return The route-index entries, ordered by path.
159    */
160   @RestGet(
161      path="/#{pathToken(${juneau.routeindex.path:options})}",
162      summary="Route index",
163      description="Content-negotiated list of @RestOp-annotated methods on the host (excluding hidden / ops endpoints).",
164      swagger=@OpSwagger(ignore=true)
165   )
166   public RouteDescriptions getRoutes(RestRequest req) {
167      return collect(resolveHostContext(req.getContext()));
168   }
169
170   private static RestContext resolveHostContext(RestContext c) {
171      var ctx = c;
172      while (ctx.isMixinContext() && ctx.getParentContext() != null)
173         ctx = ctx.getParentContext();
174      return ctx;
175   }
176
177   /**
178    * Collects the route-index entries from the supplied host {@link RestContext} and any of its
179    * registered mixin sub-contexts (test/inspection helper).
180    *
181    * @param hostCtx The host context. Must not be {@code null}.
182    * @return A list of route-index entries, ordered by path.
183    */
184   public RouteDescriptions collect(RestContext hostCtx) {
185      var seen = new HashSet<Method>();
186      var entries = new RouteDescriptions();
187      for (var oc : hostCtx.getRestOperations().getOpContexts())
188         addEntry(entries, seen, oc);
189      for (var mixinCtx : hostCtx.getMixinContexts().values())
190         for (var oc : mixinCtx.getRestOperations().getOpContexts())
191            addEntry(entries, seen, oc);
192      entries.sort(RouteIndexMixin::compareByPathThenMethod);
193      return entries;
194   }
195
196   private static void addEntry(RouteDescriptions entries, Set<Method> seen, RestOpContext oc) {
197      var m = oc.getJavaMethod();
198      if (m == null || ! seen.add(m))
199         return;
200      if (isHiddenFromIndex(m))
201         return;
202      if (isSelfHandler(m))
203         return;
204      entries.append(
205         oc.getPathPattern(),
206         List.of(oc.getHttpMethod()),
207         readSummary(m),
208         readDescription(m),
209         m.isAnnotationPresent(Deprecated.class) || m.getDeclaringClass().isAnnotationPresent(Deprecated.class)
210      );
211   }
212
213   private static boolean isHiddenFromIndex(Method m) {
214      for (var aClass : REST_OP_ANNOTATIONS) {
215         var a = m.getAnnotation(aClass);
216         if (a == null)
217            continue;
218         try {
219            var sw = aClass.getMethod("swagger").invoke(a);
220            if (sw instanceof OpSwagger os && os.ignore())
221               return true;
222         } catch (ReflectiveOperationException e) {
223            // Annotation chain shape mismatch — treat as not-hidden so we don't silently drop
224            // real entries on a future refactor.
225            return false;
226         }
227      }
228      return false;
229   }
230
231   private static boolean isSelfHandler(Method m) {
232      return m.getDeclaringClass() == RouteIndexMixin.class;
233   }
234
235   private static String readSummary(Method m) {
236      for (var aClass : REST_OP_ANNOTATIONS) {
237         var a = m.getAnnotation(aClass);
238         if (a == null)
239            continue;
240         try {
241            var s = (String) aClass.getMethod("summary").invoke(a);
242            if (s != null && ! s.isEmpty())
243               return s;
244         } catch (ReflectiveOperationException e) {
245            // fall through
246         }
247      }
248      return "";
249   }
250
251   private static String readDescription(Method m) {
252      for (var aClass : REST_OP_ANNOTATIONS) {
253         var a = m.getAnnotation(aClass);
254         if (a == null)
255            continue;
256         try {
257            var d = aClass.getMethod("description").invoke(a);
258            if (d instanceof String[] arr && arr.length > 0)
259               return String.join(" ", arr);
260            if (d instanceof String s && ! s.isEmpty())
261               return s;
262         } catch (ReflectiveOperationException e) {
263            // fall through
264         }
265      }
266      return "";
267   }
268
269   private static int compareByPathThenMethod(RouteDescription a, RouteDescription b) {
270      var c = String.valueOf(a.getPath()).compareTo(String.valueOf(b.getPath()));
271      if (c != 0)
272         return c;
273      return String.valueOf(a.getMethods()).compareTo(String.valueOf(b.getMethods()));
274   }
275}