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 — 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}} — 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/}) — 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="/{juneau.routeindex.path:options}")} 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 * — 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<Item> 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)} — 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 — 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 — route-index has no configurable state. */ 144 public RouteIndexMixin() { /* intentionally empty */ } 145 146 /** 147 * [GET /options] — 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 — mirroring the way {@code NavigationMixin.getChildren(...)} backs the child-resource 155 * navigation page. 156 * 157 * @param req The current REST request — 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}