preparing for a conceptual refactoring...
This commit is contained in:
+126
@@ -0,0 +1,126 @@
|
||||
package dev.relism.ext.routeviewer;
|
||||
|
||||
import dev.relism.ext.routeviewer.model.RouteGraph;
|
||||
import dev.relism.ext.routeviewer.model.RouterNode;
|
||||
import dev.relism.ext.routeviewer.model.RouteRecord;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Converts a {@link RouteGraph} to the JSON structure consumed by the React frontend.
|
||||
*
|
||||
* <p>Produces a {@code {"nodes":[...],"edges":[...]}} payload compatible with
|
||||
* {@code @xyflow/react}. No external JSON library required — the graph schema is
|
||||
* simple and static enough for manual serialization.
|
||||
*
|
||||
* <h3>Graph structure per route</h3>
|
||||
* <pre>
|
||||
* [MW₀] → [MW₁] → ... → [RouteNode] → [ConcreteHandler] → [ParentHandler] → ...
|
||||
* ↑
|
||||
* [RouterNode]
|
||||
* </pre>
|
||||
* Middleware is per-route (not deduplicated), handler chain is per-route.
|
||||
*/
|
||||
final class GraphSerializer {
|
||||
|
||||
private GraphSerializer() {}
|
||||
|
||||
static String toJson(RouteGraph graph) {
|
||||
List<String> nodes = new ArrayList<>();
|
||||
List<String> edges = new ArrayList<>();
|
||||
int[] eid = {0};
|
||||
|
||||
for (RouterNode router : graph.nodes()) {
|
||||
String routerId = "router:" + router.namespace();
|
||||
nodes.add(routerNode(routerId, router));
|
||||
|
||||
for (RouteRecord route : router.routes()) {
|
||||
String method = route.event().method().name();
|
||||
String path = route.event().path();
|
||||
String routeId = "route:" + method + ":" + path;
|
||||
|
||||
nodes.add(routeNode(routeId, route));
|
||||
edges.add(edge("e" + eid[0]++, routerId, routeId, "contains"));
|
||||
|
||||
// Middleware chain: [mw0] → [mw1] → ... → [route]
|
||||
List<String> mwNames = route.middlewareNames();
|
||||
String prevMwId = null;
|
||||
for (int i = 0; i < mwNames.size(); i++) {
|
||||
String mwId = "mw:" + routeId + ":" + i;
|
||||
nodes.add(middlewareNode(mwId, mwNames.get(i), i));
|
||||
if (prevMwId != null)
|
||||
edges.add(edge("e" + eid[0]++, prevMwId, mwId, "wraps"));
|
||||
prevMwId = mwId;
|
||||
}
|
||||
if (prevMwId != null)
|
||||
edges.add(edge("e" + eid[0]++, prevMwId, routeId, "wraps"));
|
||||
|
||||
// Handler abstraction chain: [route] → [concrete] → [parent] → ...
|
||||
List<String> chain = route.abstractionChain();
|
||||
String prevHandlerId = null;
|
||||
for (int i = 0; i < chain.size(); i++) {
|
||||
String handlerId = "handler:" + routeId + ":" + chain.get(i);
|
||||
nodes.add(handlerNode(handlerId, chain.get(i), i));
|
||||
if (i == 0)
|
||||
edges.add(edge("e" + eid[0]++, routeId, handlerId, "handles"));
|
||||
else
|
||||
edges.add(edge("e" + eid[0]++, prevHandlerId, handlerId, "extends"));
|
||||
prevHandlerId = handlerId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "{\"nodes\":[" + String.join(",", nodes) +
|
||||
"],\"edges\":[" + String.join(",", edges) + "]}";
|
||||
}
|
||||
|
||||
// ── Node builders ─────────────────────────────────────────────────────────
|
||||
|
||||
private static String routerNode(String id, RouterNode r) {
|
||||
return obj("id", id, "type", "router",
|
||||
"data", raw("{\"namespace\":" + q(r.namespace()) +
|
||||
",\"routerType\":" + q(r.routerType()) +
|
||||
",\"routeCount\":" + r.routes().size() + "}"));
|
||||
}
|
||||
|
||||
private static String routeNode(String id, RouteRecord route) {
|
||||
return obj("id", id, "type", "route",
|
||||
"data", raw("{\"method\":" + q(route.event().method().name()) +
|
||||
",\"path\":" + q(route.event().path()) + "}"));
|
||||
}
|
||||
|
||||
private static String handlerNode(String id, String name, int depth) {
|
||||
return obj("id", id, "type", "handler",
|
||||
"data", raw("{\"name\":" + q(name) + ",\"depth\":" + depth + "}"));
|
||||
}
|
||||
|
||||
private static String middlewareNode(String id, String name, int order) {
|
||||
return obj("id", id, "type", "middleware",
|
||||
"data", raw("{\"name\":" + q(name) + ",\"order\":" + order + "}"));
|
||||
}
|
||||
|
||||
private static String edge(String id, String source, String target, String type) {
|
||||
return "{\"id\":" + q(id) + ",\"source\":" + q(source) +
|
||||
",\"target\":" + q(target) + ",\"edgeType\":" + q(type) + "}";
|
||||
}
|
||||
|
||||
// ── JSON helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Builds a JSON object from alternating key/value pairs (values must already be JSON). */
|
||||
private static String obj(String k1, String v1, String k2, String v2,
|
||||
String k3, RawJson v3) {
|
||||
return "{" + q(k1) + ":" + q(v1) + "," + q(k2) + ":" + q(v2) + "," + q(k3) + ":" + v3.json + "}";
|
||||
}
|
||||
|
||||
private static RawJson raw(String json) { return new RawJson(json); }
|
||||
private record RawJson(String json) {}
|
||||
|
||||
/** JSON-escapes and quotes a string. */
|
||||
private static String q(String s) {
|
||||
return "\"" + s.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r") + "\"";
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package dev.relism.ext.routeviewer;
|
||||
|
||||
import dev.relism.ext.routeviewer.model.RouteGraph;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.Response;
|
||||
|
||||
/**
|
||||
* Serves {@code GET /routeviewer/data} — the JSON payload consumed by the React SPA.
|
||||
*
|
||||
* <p>The {@link RouteGraph} is frozen at startup; this handler is pure read-only
|
||||
* and produces no allocations beyond the response string itself.
|
||||
*/
|
||||
class RouteViewerDataHandler {
|
||||
|
||||
private final RouteGraph graph;
|
||||
/** Cached once — the graph never changes after boot. */
|
||||
private volatile String cachedJson;
|
||||
|
||||
RouteViewerDataHandler(RouteGraph graph) {
|
||||
this.graph = graph;
|
||||
}
|
||||
|
||||
Object handle(Request req, Response res) {
|
||||
if (cachedJson == null) cachedJson = GraphSerializer.toJson(graph);
|
||||
res.setContentType(ContentType.JSON);
|
||||
res.header("Cache-Control", "no-cache");
|
||||
return cachedJson;
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package dev.relism.ext.routeviewer;
|
||||
|
||||
import dev.relism.ext.routeviewer.model.RouteGraph;
|
||||
import dev.relism.extension.ExtensionContext;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
import dev.relism.extension.FlashRegistrar;
|
||||
import dev.relism.http.ContentType;
|
||||
|
||||
/**
|
||||
* Mounts an interactive route-graph viewer at a configurable HTTP endpoint.
|
||||
*
|
||||
* <p>The viewer is a React SPA ({@code @xyflow/react}) bundled into the JAR.
|
||||
* It renders routers, routes, handler inheritance chains, and middleware chains
|
||||
* as a draggable, zoomable node graph.
|
||||
*
|
||||
* <h3>Endpoints registered</h3>
|
||||
* <ul>
|
||||
* <li>{@code GET <path>} — SPA shell (index.html)</li>
|
||||
* <li>{@code GET <path>/app.js} — React bundle</li>
|
||||
* <li>{@code GET <path>/app.css} — styles</li>
|
||||
* <li>{@code GET <path>/data} — graph JSON consumed by the SPA</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h3>Install order</h3>
|
||||
* Install <em>after</em> extensions that register annotation processors
|
||||
* (e.g. {@code OidcExtension}) but <em>before</em> {@code scan()} or
|
||||
* {@code register()} calls so the listener captures all routes:
|
||||
*
|
||||
* <pre>{@code
|
||||
* FlashApp.create(8080)
|
||||
* .install(new OidcExtension(config))
|
||||
* .install(new JacksonExtension())
|
||||
* .install(new RouteViewerExtension()) // before scan
|
||||
* .scan("dev.example.handlers")
|
||||
* .start();
|
||||
* }</pre>
|
||||
*
|
||||
* <p>All route metadata is collected once at boot time via
|
||||
* {@link ExtensionContext#addRouteListener}. Zero overhead on the request hot-path.
|
||||
*/
|
||||
public class RouteViewerExtension implements FlashExtension {
|
||||
|
||||
public static final String DEFAULT_PATH = "/routeviewer";
|
||||
|
||||
private final String path;
|
||||
private final RouteGraph graph = new RouteGraph();
|
||||
|
||||
/** Installs the viewer at {@value #DEFAULT_PATH}. */
|
||||
public RouteViewerExtension() { this(DEFAULT_PATH); }
|
||||
|
||||
/**
|
||||
* Installs the viewer at a custom path.
|
||||
* @param path e.g. {@code "/_routes"}
|
||||
*/
|
||||
public RouteViewerExtension(String path) { this.path = path; }
|
||||
|
||||
@Override
|
||||
public void install(FlashRegistrar app, ExtensionContext ctx) {
|
||||
RouteViewerHandler shell = new RouteViewerHandler();
|
||||
RouteViewerDataHandler data = new RouteViewerDataHandler(graph);
|
||||
|
||||
// Static assets (Vite build output, bundled in JAR)
|
||||
app.get(path, shell::handle);
|
||||
app.get(path + "/app.js", new RouteViewerStaticHandler("routeviewer/app.js", ContentType.TEXT_JAVASCRIPT)::handle);
|
||||
app.get(path + "/app.css", new RouteViewerStaticHandler("routeviewer/app.css", ContentType.TEXT_CSS)::handle);
|
||||
|
||||
// Graph data API — must be registered before the listener so it is
|
||||
// flushed and captured AFTER the listener is attached (shows in the graph)
|
||||
app.get(path + "/data", data::handle);
|
||||
|
||||
// Start listening — routes registered after this point are captured
|
||||
ctx.addRouteListener(graph::add);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.relism.ext.routeviewer;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Serves the route-viewer SPA shell ({@code index.html}) from the classpath.
|
||||
*
|
||||
* <p>The HTML file is produced by the Vite build of {@code routeviewer-ui/}
|
||||
* and packaged into the JAR under {@code routeviewer/index.html}.
|
||||
* The SPA then fetches {@code /routeviewer/data} for the graph payload.
|
||||
*/
|
||||
class RouteViewerHandler {
|
||||
|
||||
private static final String RESOURCE = "routeviewer/index.html";
|
||||
private static final String FALLBACK =
|
||||
"<h2 style='font-family:monospace;padding:2rem'>Route Viewer UI not built." +
|
||||
"<br>Run: <code>cd routeviewer-ui && pnpm build</code></h2>";
|
||||
|
||||
private volatile byte[] cached;
|
||||
|
||||
Object handle(Request req, Response res) throws IOException {
|
||||
if (cached == null) cached = load();
|
||||
res.setContentType(ContentType.TEXT_HTML);
|
||||
return cached;
|
||||
}
|
||||
|
||||
private byte[] load() throws IOException {
|
||||
try (InputStream in = RouteViewerHandler.class
|
||||
.getClassLoader().getResourceAsStream(RESOURCE)) {
|
||||
return in != null ? in.readAllBytes() : FALLBACK.getBytes();
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.relism.ext.routeviewer;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Serves a single static file from the classpath (bundled inside the JAR).
|
||||
*
|
||||
* <p>Used to expose the Vite-built assets ({@code app.js}, {@code app.css})
|
||||
* that the route-viewer SPA needs.
|
||||
*/
|
||||
class RouteViewerStaticHandler {
|
||||
|
||||
private final String classpathResource;
|
||||
private final ContentType contentType;
|
||||
/** Cached bytes — static assets never change after startup. */
|
||||
private volatile byte[] cached;
|
||||
|
||||
RouteViewerStaticHandler(String classpathResource, ContentType contentType) {
|
||||
this.classpathResource = classpathResource;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
Object handle(Request req, Response res) throws IOException {
|
||||
if (cached == null) cached = load();
|
||||
if (cached == null) {
|
||||
res.setStatusCode(404);
|
||||
return null;
|
||||
}
|
||||
res.setStatusCode(200);
|
||||
res.setContentType(contentType);
|
||||
res.header("Cache-Control", "public, max-age=3600");
|
||||
return cached;
|
||||
}
|
||||
|
||||
private byte[] load() throws IOException {
|
||||
try (InputStream in = RouteViewerStaticHandler.class
|
||||
.getClassLoader().getResourceAsStream(classpathResource)) {
|
||||
return in == null ? null : in.readAllBytes();
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.relism.ext.routeviewer.model;
|
||||
|
||||
import dev.relism.extension.RouteEvent;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Accumulates {@link RouteEvent}s at boot time and organizes them into an
|
||||
* ordered list of {@link RouterNode}s for rendering.
|
||||
*
|
||||
* <p>Thread-safety: events are emitted sequentially at registration time
|
||||
* (single-threaded boot), so no synchronization is needed here.
|
||||
*/
|
||||
public class RouteGraph {
|
||||
|
||||
/** Events in registration order, grouped by namespace. */
|
||||
private final Map<String, List<RouteRecord>> byNamespace = new LinkedHashMap<>();
|
||||
/** Namespace → routerType, filled on first event for each namespace. */
|
||||
private final Map<String, String> routerTypes = new LinkedHashMap<>();
|
||||
|
||||
/** Called once per route by the {@link dev.relism.extension.RouteListener}. */
|
||||
public void add(RouteEvent event) {
|
||||
routerTypes.putIfAbsent(event.namespace(), event.routerType());
|
||||
byNamespace
|
||||
.computeIfAbsent(event.namespace(), k -> new ArrayList<>())
|
||||
.add(RouteRecord.from(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the route graph as an ordered list of {@link RouterNode}s,
|
||||
* sorted from shortest namespace to longest (root first, deepest last).
|
||||
*/
|
||||
public List<RouterNode> nodes() {
|
||||
return byNamespace.entrySet().stream()
|
||||
.sorted(Comparator.comparingInt(e -> e.getKey().length()))
|
||||
.map(e -> new RouterNode(e.getKey(), routerTypes.get(e.getKey()), List.copyOf(e.getValue())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Total number of registered routes across all routers. */
|
||||
public int totalRoutes() {
|
||||
return byNamespace.values().stream().mapToInt(List::size).sum();
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package dev.relism.ext.routeviewer.model;
|
||||
|
||||
import dev.relism.extension.RouteEvent;
|
||||
import dev.relism.routing.Middleware;
|
||||
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Enriched snapshot of a single registered route.
|
||||
*
|
||||
* <p>Built once at registration time from a {@link RouteEvent}.
|
||||
* All expensive operations (superclass traversal, annotation reading)
|
||||
* happen here — never on the request hot-path.
|
||||
*
|
||||
* @param event the raw event emitted by the Flash core
|
||||
* @param abstractionChain handler class hierarchy, outermost first, stopping before
|
||||
* {@code RequestHandler} (e.g. {@code ["EditPostPageHandler", "HtmlHandler"]}).
|
||||
* Empty for anonymous lambda handlers.
|
||||
* @param pointcuts semantic annotations declared on the handler class hierarchy,
|
||||
* used as declarative pointcut descriptors
|
||||
* @param middlewareNames cleaned simple class names of the middleware chain, outermost first
|
||||
*/
|
||||
public record RouteRecord(
|
||||
RouteEvent event,
|
||||
List<String> abstractionChain,
|
||||
List<String> pointcuts,
|
||||
List<String> middlewareNames
|
||||
) {
|
||||
|
||||
/** The root class we stop at (exclusive) — always implied, never shown. */
|
||||
private static final String ROOT_HANDLER = "RequestHandler";
|
||||
|
||||
/** Builds a {@code RouteRecord} from a raw {@link RouteEvent}. */
|
||||
public static RouteRecord from(RouteEvent event) {
|
||||
return new RouteRecord(
|
||||
event,
|
||||
buildAbstractionChain(event.handlerClass()),
|
||||
buildPointcuts(event.handlerClass()),
|
||||
buildMiddlewareNames(event.middlewareChain())
|
||||
);
|
||||
}
|
||||
|
||||
// ── Builders ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walks the superclass chain, stopping before {@code RequestHandler}.
|
||||
* {@code RequestHandler} is the universal root — showing it adds no information.
|
||||
*/
|
||||
private static List<String> buildAbstractionChain(Class<?> cls) {
|
||||
if (cls == null) return List.of();
|
||||
List<String> chain = new ArrayList<>();
|
||||
Class<?> c = cls;
|
||||
while (c != null && !c.equals(Object.class)) {
|
||||
if (ROOT_HANDLER.equals(c.getSimpleName())) break;
|
||||
chain.add(c.getSimpleName());
|
||||
c = c.getSuperclass();
|
||||
}
|
||||
return List.copyOf(chain);
|
||||
}
|
||||
|
||||
private static List<String> buildPointcuts(Class<?> cls) {
|
||||
if (cls == null) return List.of();
|
||||
List<String> pointcuts = new ArrayList<>();
|
||||
Class<?> c = cls;
|
||||
while (c != null && !c.equals(Object.class)) {
|
||||
if (ROOT_HANDLER.equals(c.getSimpleName())) break;
|
||||
for (Annotation ann : c.getDeclaredAnnotations()) {
|
||||
String name = ann.annotationType().getSimpleName();
|
||||
if (!name.equals("Route") && !name.equals("Override"))
|
||||
pointcuts.add(formatAnnotation(ann));
|
||||
}
|
||||
c = c.getSuperclass();
|
||||
}
|
||||
return List.copyOf(pointcuts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the synthetic lambda suffix ({@code $$Lambda/0x...}) from class names
|
||||
* so that {@code OidcMiddleware$$Lambda/0x0000019c381f} becomes {@code OidcMiddleware}.
|
||||
*/
|
||||
private static List<String> buildMiddlewareNames(List<Class<? extends Middleware>> chain) {
|
||||
List<String> names = new ArrayList<>(chain.size());
|
||||
for (Class<? extends Middleware> cls : chain) names.add(cleanName(cls.getSimpleName()));
|
||||
return List.copyOf(names);
|
||||
}
|
||||
|
||||
private static String cleanName(String simpleName) {
|
||||
int dollar = simpleName.indexOf("$$");
|
||||
return dollar >= 0 ? simpleName.substring(0, dollar) : simpleName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an annotation for display.
|
||||
* <ul>
|
||||
* <li>Marker annotations → {@code @Name}</li>
|
||||
* <li>{@code String} value → {@code @Name(value)}</li>
|
||||
* <li>{@code String[]} value → {@code @Name(a, b)}</li>
|
||||
* <li>Any other value type (annotation arrays, class refs, etc.) → {@code @Name}
|
||||
* — avoids ugly {@code [Ldev.relism...;@hash} output</li>
|
||||
* </ul>
|
||||
*/
|
||||
private static String formatAnnotation(Annotation ann) {
|
||||
try {
|
||||
Object value = ann.annotationType().getMethod("value").invoke(ann);
|
||||
String v;
|
||||
if (value instanceof String s) v = s;
|
||||
else if (value instanceof String[] arr) v = String.join(", ", arr);
|
||||
else return "@" + ann.annotationType().getSimpleName(); // complex type — skip value
|
||||
return "@" + ann.annotationType().getSimpleName() + "(" + v + ")";
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
return "@" + ann.annotationType().getSimpleName();
|
||||
} catch (Exception e) {
|
||||
return "@" + ann.annotationType().getSimpleName();
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.relism.ext.routeviewer.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A node in the route graph representing a single router instance.
|
||||
*
|
||||
* <p>Routers are identified by their namespace prefix. The hierarchy
|
||||
* (parent/child relationships) is inferred by prefix matching — a router
|
||||
* with namespace {@code "/api/users"} is a child of {@code "/api"}.
|
||||
*
|
||||
* @param namespace the router's namespace prefix (e.g. {@code "/"}, {@code "/api"})
|
||||
* @param routerType simple class name of the router implementation (e.g. {@code "FastPathRouterImpl"})
|
||||
* @param routes routes registered directly on this router, in registration order
|
||||
*/
|
||||
public record RouterNode(
|
||||
String namespace,
|
||||
String routerType,
|
||||
List<RouteRecord> routes
|
||||
) {
|
||||
|
||||
/** Returns {@code true} if {@code other} is a direct or indirect parent of this node. */
|
||||
public boolean isChildOf(RouterNode other) {
|
||||
if (this.namespace.equals(other.namespace)) return false;
|
||||
return this.namespace.startsWith(other.namespace.equals("/") ? "/" : other.namespace + "/");
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Flash Route Viewer</title>
|
||||
<script type="module" crossorigin src="/routeviewer/app.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/routeviewer/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user