preparing for a conceptual refactoring...

This commit is contained in:
Relism
2026-03-28 14:11:12 +01:00
parent 7b996b552b
commit 2edd68b0aa
60 changed files with 3432 additions and 518 deletions
@@ -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") + "\"";
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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();
}
}
}
@@ -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();
}
}
}
@@ -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();
}
}
@@ -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();
}
}
}
@@ -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>