preparing for a conceptual refactoring...
This commit is contained in:
@@ -23,7 +23,7 @@ Jackson JSON integration for the Flash HTTP server.
|
||||
Install before any extension that needs JSON (e.g. `flash-ext-openapi`, `flash-ext-oidc`):
|
||||
|
||||
```java
|
||||
FlashApp.of(new HttpServer(config))
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension())
|
||||
// ... other extensions
|
||||
```
|
||||
@@ -36,7 +36,7 @@ ObjectMapper mapper = JsonMapper.builder()
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build();
|
||||
|
||||
FlashApp.of(new HttpServer(config))
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension(mapper));
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import dev.relism.exceptions.HttpException;
|
||||
import dev.relism.extension.ExtensionContext;
|
||||
import dev.relism.extension.FlashApp;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
import dev.relism.extension.FlashRegistrar;
|
||||
import dev.relism.http.ContentType;
|
||||
|
||||
/**
|
||||
@@ -45,7 +45,7 @@ public class JacksonExtension implements FlashExtension {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void install(FlashApp app, ExtensionContext ctx) {
|
||||
public void install(FlashRegistrar app, ExtensionContext ctx) {
|
||||
ctx.provide(ObjectMapper.class, mapper);
|
||||
JacksonHandler.mapper = mapper;
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to
|
||||
## Installation
|
||||
|
||||
```java
|
||||
FlashApp.of(new HttpServer(config))
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension())
|
||||
.install(new OpenApiExtension(...)) // optional — enables Swagger security
|
||||
.install(new OidcExtension(
|
||||
@@ -144,17 +144,17 @@ OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
|
||||
app.get("/api/me", (req, res) -> {
|
||||
OidcUser u = ClaimsHolder.user(); // never null here
|
||||
return Map.of("sub", u.sub(), "email", u.email());
|
||||
}, oidc.protect());
|
||||
}).with(oidc.protect());
|
||||
|
||||
// Authentication + role check
|
||||
app.delete("/api/admin/users/{id}", (req, res) -> {
|
||||
OidcUser u = ClaimsHolder.user();
|
||||
// ...
|
||||
}, oidc.requireRole("admin"));
|
||||
}).with(oidc.requireRole("admin"));
|
||||
|
||||
// Multiple roles (OR): passes if user holds any one of them
|
||||
app.get("/api/reports", (req, res) -> { ... },
|
||||
oidc.requireRole("admin", "reports-viewer"));
|
||||
app.get("/api/reports", (req, res) -> { ... })
|
||||
.with(oidc.requireRole("admin", "reports-viewer"));
|
||||
```
|
||||
|
||||
`oidc.protect()` / `oidc.requireRole(...)` return a `Middleware` — a composable
|
||||
@@ -326,8 +326,8 @@ OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // tenantA's mid
|
||||
app.install(new OidcExtension(tenantB));
|
||||
OidcMiddleware mwB = app.ctx().require(OidcMiddleware.class); // tenantB's middleware
|
||||
|
||||
app.get("/a/dashboard", (req, res) -> { ... }, mwA.protect());
|
||||
app.get("/b/dashboard", (req, res) -> { ... }, mwB.protect());
|
||||
app.get("/a/dashboard", (req, res) -> { ... }).with(mwA.protect());
|
||||
app.get("/b/dashboard", (req, res) -> { ... }).with(mwB.protect());
|
||||
```
|
||||
|
||||
Class-based handlers annotated with `@Authenticated` / `@RolesAllowed` get the last
|
||||
|
||||
@@ -11,13 +11,30 @@ import java.lang.annotation.Target;
|
||||
*
|
||||
* <p>For role-based access use {@link RolesAllowed} instead (it implies authentication).
|
||||
*
|
||||
* <p>Set {@code optional = true} on public routes that personalise their response when
|
||||
* the user happens to be logged in but should remain accessible to guests. The middleware
|
||||
* will populate {@link ClaimsHolder} if credentials are present and silently skip it
|
||||
* otherwise — the request is never rejected.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Hard auth — redirects / 401 when unauthenticated:
|
||||
* @Route(method = HttpMethod.GET, path = "/api/profile")
|
||||
* @Authenticated
|
||||
* public class GetProfile extends JacksonHandler { ... }
|
||||
*
|
||||
* // Soft auth — guest-friendly, ClaimsHolder populated only when logged in:
|
||||
* @Route(method = HttpMethod.GET, path = "/")
|
||||
* @Authenticated(optional = true)
|
||||
* public class HomePage extends HtmlHandler { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface Authenticated {
|
||||
/**
|
||||
* When {@code true} the middleware never rejects unauthenticated requests — it only
|
||||
* populates {@link ClaimsHolder} when valid credentials are present.
|
||||
* Defaults to {@code false} (hard authentication required).
|
||||
*/
|
||||
boolean optional() default false;
|
||||
}
|
||||
|
||||
+10
-10
@@ -1,8 +1,8 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import dev.relism.extension.ExtensionContext;
|
||||
import dev.relism.extension.FlashApp;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
import dev.relism.extension.FlashRegistrar;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManager;
|
||||
@@ -59,7 +59,7 @@ public class OidcExtension implements FlashExtension {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void install(FlashApp app, ExtensionContext ctx) {
|
||||
public void install(FlashRegistrar app, ExtensionContext ctx) {
|
||||
|
||||
// 1. Build the shared HttpClient (optionally with TLS verification disabled)
|
||||
HttpClient http = buildHttpClient(config);
|
||||
@@ -116,7 +116,7 @@ public class OidcExtension implements FlashExtension {
|
||||
+ "&code_challenge=" + challenge
|
||||
+ "&code_challenge_method=S256";
|
||||
|
||||
res.status(302).header("Location", authUrl);
|
||||
res.redirect(authUrl);
|
||||
return null;
|
||||
}).with();
|
||||
|
||||
@@ -165,9 +165,8 @@ public class OidcExtension implements FlashExtension {
|
||||
);
|
||||
config.sessionStore().save(session);
|
||||
|
||||
res.status(302)
|
||||
.header("Set-Cookie", sessionCookie(session.id()))
|
||||
.header("Location", entry.originalUrl());
|
||||
res.header("Set-Cookie", sessionCookie(session.id()))
|
||||
.redirect(entry.originalUrl());
|
||||
return null;
|
||||
}).with();
|
||||
|
||||
@@ -200,9 +199,8 @@ public class OidcExtension implements FlashExtension {
|
||||
location = config.postLogoutRedirectUri();
|
||||
}
|
||||
|
||||
res.status(302)
|
||||
.header("Set-Cookie", clearCookie)
|
||||
.header("Location", location);
|
||||
res.header("Set-Cookie", clearCookie)
|
||||
.redirect(location);
|
||||
return null;
|
||||
}).with();
|
||||
|
||||
@@ -212,7 +210,9 @@ public class OidcExtension implements FlashExtension {
|
||||
if (roles != null) return List.of(oidcMw.rolesMiddleware(roles.value()));
|
||||
|
||||
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
||||
if (auth != null) return List.of(oidcMw.authenticatedMiddleware());
|
||||
if (auth != null) return List.of(auth.optional()
|
||||
? oidcMw.optionalMiddleware()
|
||||
: oidcMw.authenticatedMiddleware());
|
||||
|
||||
return List.of();
|
||||
});
|
||||
|
||||
+54
-1
@@ -68,6 +68,29 @@ public class OidcMiddleware {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Silently populates {@link ClaimsHolder} if a valid bearer token or session cookie
|
||||
* is present, but never rejects or redirects unauthenticated requests. Use this on
|
||||
* public routes that want to personalise the response when the user happens to be
|
||||
* logged in (e.g. showing a username on a landing page).
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.get("/", handler).with(oidc.optional());
|
||||
* // Inside handler: ClaimsHolder.user() is non-null iff the user is logged in.
|
||||
* }</pre>
|
||||
*/
|
||||
public Middleware optional() {
|
||||
return next -> (req, res) -> {
|
||||
Map<String, Object> claims = resolveQuiet(req);
|
||||
if (claims != null) ClaimsHolder.set(claims);
|
||||
try {
|
||||
return next.handle(req, res);
|
||||
} finally {
|
||||
ClaimsHolder.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #protect()} but also enforces that the caller holds at least one
|
||||
* of the given roles (OR semantics). Roles are extracted via
|
||||
@@ -90,10 +113,40 @@ public class OidcMiddleware {
|
||||
// -- Package-private: AnnotationProcessor hooks ---------------------------
|
||||
|
||||
Middleware authenticatedMiddleware() { return protect(); }
|
||||
Middleware optionalMiddleware() { return optional(); }
|
||||
Middleware rolesMiddleware(String[] required) { return requireRole(required); }
|
||||
|
||||
// -- Internals ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Like {@link #resolve} but never redirects or throws — returns {@code null} silently
|
||||
* when no valid credentials are present. Used by {@link #optional()}.
|
||||
*/
|
||||
private Map<String, Object> resolveQuiet(Request req) {
|
||||
String auth = req.header("Authorization");
|
||||
if (auth != null && auth.startsWith("Bearer "))
|
||||
return validator.validate(auth.substring(7));
|
||||
|
||||
String sessionId = cookieValue(req, "oidc_session");
|
||||
if (sessionId != null) {
|
||||
Optional<OidcSession> found = config.sessionStore().find(sessionId);
|
||||
if (found.isPresent()) {
|
||||
OidcSession session = found.get();
|
||||
if (!session.isAccessTokenExpired())
|
||||
return session.claims();
|
||||
if (session.refreshToken() != null) {
|
||||
try {
|
||||
OidcSession refreshed = doRefresh(session);
|
||||
config.sessionStore().save(refreshed);
|
||||
return refreshed.claims();
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
config.sessionStore().delete(sessionId);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns claims on success, or {@code null} if a redirect was already written to
|
||||
* {@code res}. Throws {@link HttpException} 401/403 for API clients.
|
||||
@@ -136,7 +189,7 @@ public class OidcMiddleware {
|
||||
// Browser — redirect to login, preserving the original URL in state
|
||||
String loginUrl = config.routePrefix() + "/login?redirect="
|
||||
+ URLEncoder.encode(req.path(), StandardCharsets.UTF_8);
|
||||
res.status(302).header("Location", loginUrl);
|
||||
res.redirect(loginUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ If `flash-ext-oidc` is installed **after** this extension, OIDC security schemes
|
||||
## Installation
|
||||
|
||||
```java
|
||||
FlashApp.of(new HttpServer(config))
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension())
|
||||
.install(new OpenApiExtension("/openapi", "My API", "2.0.0", "Optional description"))
|
||||
.register(new MyHandler());
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@ package dev.relism.ext.openapi;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
|
||||
import dev.relism.extension.ExtensionContext;
|
||||
import dev.relism.extension.FlashApp;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
import dev.relism.extension.FlashRegistrar;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.routing.Route;
|
||||
|
||||
@@ -61,7 +61,7 @@ public class OpenApiExtension implements FlashExtension {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void install(FlashApp app, ExtensionContext ctx) {
|
||||
public void install(FlashRegistrar app, ExtensionContext ctx) {
|
||||
ObjectMapper jsonMapper = ctx.require(ObjectMapper.class);
|
||||
YAMLMapper yamlMapper = new YAMLMapper();
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-routeviewer</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<!--
|
||||
Builds the React SPA before Java compilation.
|
||||
Requires Node 22+ and pnpm 9+ to be available.
|
||||
Output lands in src/main/resources/routeviewer/ → packaged into the JAR.
|
||||
The frontend source (routeviewer-ui/) is NOT included in the JAR.
|
||||
-->
|
||||
<plugin>
|
||||
<groupId>com.github.eirslett</groupId>
|
||||
<artifactId>frontend-maven-plugin</artifactId>
|
||||
<version>1.15.0</version>
|
||||
<configuration>
|
||||
<workingDirectory>routeviewer-ui</workingDirectory>
|
||||
<installDirectory>target/frontend-runtime</installDirectory>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>install-node-and-pnpm</id>
|
||||
<goals><goal>install-node-and-pnpm</goal></goals>
|
||||
<phase>initialize</phase>
|
||||
<configuration>
|
||||
<nodeVersion>v22.11.0</nodeVersion>
|
||||
<pnpmVersion>9.12.0</pnpmVersion>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>pnpm-install</id>
|
||||
<goals><goal>pnpm</goal></goals>
|
||||
<phase>initialize</phase>
|
||||
<configuration>
|
||||
<arguments>install --frozen-lockfile</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>pnpm-build</id>
|
||||
<goals><goal>pnpm</goal></goals>
|
||||
<phase>generate-resources</phase>
|
||||
<configuration>
|
||||
<arguments>build</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "routeviewer-ui",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@xyflow/react": "^12.3.6",
|
||||
"html-to-image": "^1.11.11",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
.app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0f1117;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
background: #1a1d27;
|
||||
border-bottom: 1px solid #2e3347;
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logo { font-weight: 700; font-size: 15px; letter-spacing: -.3px; }
|
||||
|
||||
.badge {
|
||||
background: #7c6af7;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: #8892a4;
|
||||
}
|
||||
|
||||
.legend-item { display: flex; align-items: center; gap: 5px; }
|
||||
|
||||
.dot {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.dot-dash {
|
||||
border: 2px dashed;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
.muted { color: #8892a4; }
|
||||
.error { color: #f87171; }
|
||||
|
||||
/* ReactFlow override */
|
||||
.react-flow__renderer { flex: 1; }
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar-content {
|
||||
padding: 16px 12px;
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #e2e8f0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-content h3 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.sidebar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.sidebar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.sidebar::-webkit-scrollbar-thumb {
|
||||
background: #2e3347;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.sidebar::-webkit-scrollbar-thumb:hover {
|
||||
background: #3d4557;
|
||||
}
|
||||
|
||||
/* Hide interactivity toggle button in Controls */
|
||||
.react-flow__controls button:nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Abstract node highlighting in MiniMap */
|
||||
.react-flow__minimap-node[data-id*="Abstract"] {
|
||||
stroke: #ef4444;
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
|
||||
import {
|
||||
ReactFlow, Background, Controls, MiniMap, ReactFlowProvider,
|
||||
useNodesState, useEdgesState, MarkerType, useReactFlow,
|
||||
getNodesBounds, getViewportForBounds,
|
||||
} from '@xyflow/react'
|
||||
import { toPng } from 'html-to-image'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import { normalizeGraph } from './normalize.js'
|
||||
import { layoutGraph } from './layout.js'
|
||||
import HandlerNode from './nodes/HandlerNode.jsx'
|
||||
import './App.css'
|
||||
|
||||
const nodeTypes = { handler: HandlerNode }
|
||||
|
||||
// ── Export helper ─────────────────────────────────────────────────────────────
|
||||
function useExport(exportRef) {
|
||||
const { getNodes } = useReactFlow()
|
||||
|
||||
useEffect(() => {
|
||||
exportRef.current = () => {
|
||||
const nodes = getNodes()
|
||||
if (!nodes.length) return
|
||||
|
||||
const bounds = getNodesBounds(nodes)
|
||||
const pad = 60
|
||||
const imgW = Math.max(1920, bounds.width + pad * 2)
|
||||
const imgH = Math.max(1080, bounds.height + pad * 2)
|
||||
const vp = getViewportForBounds(bounds, imgW, imgH, 0.1, 4, pad)
|
||||
|
||||
toPng(document.querySelector('.react-flow__viewport'), {
|
||||
backgroundColor: '#0f1117',
|
||||
width: imgW,
|
||||
height: imgH,
|
||||
style: {
|
||||
width: imgW + 'px',
|
||||
height: imgH + 'px',
|
||||
transform: `translate(${vp.x}px,${vp.y}px) scale(${vp.zoom})`,
|
||||
},
|
||||
}).then(url => {
|
||||
const a = document.createElement('a')
|
||||
a.download = 'flash-routes.png'
|
||||
a.href = url
|
||||
a.click()
|
||||
}).catch(console.error)
|
||||
}
|
||||
}, [getNodes, exportRef])
|
||||
}
|
||||
|
||||
// ── FlowContent ───────────────────────────────────────────────────────────────
|
||||
function FlowContent({
|
||||
styledNodes, styledEdges, onNodesChange, onEdgesChange,
|
||||
onNodeMouseEnter, onNodeMouseLeave, showLambdas, searchQuery, exportRef,
|
||||
}) {
|
||||
const { fitView } = useReactFlow()
|
||||
useExport(exportRef)
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => fitView({ padding: 0.15, duration: 300 }), 50)
|
||||
}, [showLambdas, searchQuery, fitView])
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={styledNodes}
|
||||
edges={styledEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeMouseEnter={onNodeMouseEnter}
|
||||
onNodeMouseLeave={onNodeMouseLeave}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
colorMode="dark"
|
||||
minZoom={0.03}
|
||||
maxZoom={2}
|
||||
panOnDrag={true}
|
||||
panOnScroll={true}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
zoomOnDoubleClick={true}
|
||||
>
|
||||
<Background color="#161822" gap={32} size={1} />
|
||||
<Controls showInteractive={false}
|
||||
style={{ background: '#12141c', border: '1px solid #1e2235' }} />
|
||||
<MiniMap
|
||||
style={{ background: '#12141c', border: '1px solid #1e2235' }}
|
||||
maskColor="rgba(0,0,0,0.5)"
|
||||
nodeColor={n => n.data?.isAbstract ? '#ef444499' : '#3b82f666'}
|
||||
/>
|
||||
</ReactFlow>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
export default function App() {
|
||||
const [allNodes, setAllNodes] = useState([])
|
||||
const [allEdges, setAllEdges] = useState([])
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [stats, setStats] = useState(null)
|
||||
const [hoveredNode, setHoveredNode] = useState(null)
|
||||
const [highlighted, setHighlighted] = useState({ nodes: new Set(), edges: new Set() })
|
||||
const [showLambdas, setShowLambdas] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const exportRef = useRef(null)
|
||||
|
||||
// ── Load data ──────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
fetch('/routeviewer/data')
|
||||
.then(r => { if (!r.ok) throw new Error(r.statusText); return r.json() })
|
||||
.then(raw => {
|
||||
const routeCount = raw.nodes.filter(n => n.type === 'route').length
|
||||
const { nodes: n, edges: e } = normalizeGraph(raw.nodes, raw.edges)
|
||||
const { nodes: ln, edges: le } = layoutGraph(n, e)
|
||||
setAllNodes(ln)
|
||||
setAllEdges(le)
|
||||
setStats({ routes: routeCount })
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(err => { setError(err.message); setLoading(false) })
|
||||
}, [])
|
||||
|
||||
// ── Ancestor set builder ───────────────────────────────────────────────────
|
||||
const getAncestors = useMemo(() => {
|
||||
const parentOf = new Map()
|
||||
allEdges.forEach(e => { if (e.edgeType === 'extends') parentOf.set(e.target, e.source) })
|
||||
return (id) => {
|
||||
const set = new Set()
|
||||
let cur = parentOf.get(id)
|
||||
while (cur) { set.add(cur); cur = parentOf.get(cur) }
|
||||
return set
|
||||
}
|
||||
}, [allEdges])
|
||||
|
||||
// ── Filter (lambda toggle + search) ───────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const q = searchQuery.trim().toLowerCase()
|
||||
const lambdaIds = new Set(allNodes.filter(n => n.data?.isLambda).map(n => n.id))
|
||||
|
||||
let visibleIds = new Set(allNodes.map(n => n.id))
|
||||
if (q) {
|
||||
const matched = new Set(
|
||||
allNodes.filter(n =>
|
||||
(n.data?.name || '').toLowerCase().includes(q) ||
|
||||
(n.data?.routes || []).some(r => r.path.toLowerCase().includes(q))
|
||||
).map(n => n.id)
|
||||
)
|
||||
const withAncestors = new Set(matched)
|
||||
matched.forEach(id => getAncestors(id).forEach(a => withAncestors.add(a)))
|
||||
visibleIds = withAncestors
|
||||
}
|
||||
|
||||
const filteredNodes = allNodes.filter(n => {
|
||||
if (lambdaIds.has(n.id) && !showLambdas) return false
|
||||
return visibleIds.has(n.id)
|
||||
})
|
||||
const filteredIds = new Set(filteredNodes.map(n => n.id))
|
||||
setNodes(filteredNodes)
|
||||
setEdges(allEdges.filter(e => filteredIds.has(e.source) && filteredIds.has(e.target)))
|
||||
}, [showLambdas, searchQuery, allNodes, allEdges, setNodes, setEdges, getAncestors])
|
||||
|
||||
// ── Hover ──────────────────────────────────────────────────────────────────
|
||||
const onNodeMouseEnter = useCallback((_, node) => {
|
||||
const parentOf = new Map()
|
||||
edges.forEach(e => { if (e.edgeType === 'extends') parentOf.set(e.target, { pid: e.source, eid: e.id }) })
|
||||
const visited = new Set([node.id])
|
||||
const connEdges = new Set()
|
||||
const q = [node.id]
|
||||
while (q.length) {
|
||||
const cur = q.shift()
|
||||
const p = parentOf.get(cur)
|
||||
if (p && !visited.has(p.pid)) { connEdges.add(p.eid); visited.add(p.pid); q.push(p.pid) }
|
||||
}
|
||||
setHoveredNode(node.id)
|
||||
setHighlighted({ nodes: visited, edges: connEdges })
|
||||
}, [edges])
|
||||
|
||||
const onNodeMouseLeave = useCallback(() => {
|
||||
setHoveredNode(null)
|
||||
setHighlighted({ nodes: new Set(), edges: new Set() })
|
||||
}, [])
|
||||
|
||||
// ── Style pass ─────────────────────────────────────────────────────────────
|
||||
const styledNodes = nodes.map(n => ({
|
||||
...n,
|
||||
style: { opacity: hoveredNode && !highlighted.nodes.has(n.id) ? 0.1 : 1, transition: 'opacity 0.15s' },
|
||||
}))
|
||||
|
||||
const styledEdges = edges.map(e => {
|
||||
const base = { type: 'bezier', pathOptions: { curvature: 0.35 },
|
||||
style: { stroke: '#1e2235', strokeWidth: 1.5 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 10, height: 10, color: '#1e2235' } }
|
||||
if (!hoveredNode) return { ...e, ...base }
|
||||
if (highlighted.edges.has(e.id)) return {
|
||||
...e, type: 'bezier', pathOptions: { curvature: 0.35 },
|
||||
style: { stroke: '#60a5fa', strokeWidth: 2.5 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 13, height: 13, color: '#60a5fa' },
|
||||
}
|
||||
return { ...e, ...base, style: { ...base.style, opacity: 0.04 } }
|
||||
})
|
||||
|
||||
const lambdaCount = allNodes.filter(n => n.data?.isLambda).length
|
||||
const handlerCount = allNodes.filter(n => !n.data?.isLambda).length
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
setExporting(true)
|
||||
setTimeout(() => {
|
||||
exportRef.current?.()
|
||||
setTimeout(() => setExporting(false), 1200)
|
||||
}, 50)
|
||||
}, [])
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div style={{ display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden', background: '#0f1117' }}>
|
||||
|
||||
{/* ── Sidebar ──────────────────────────────────────────────────────── */}
|
||||
<aside style={{
|
||||
width: '240px', minWidth: '240px', flexShrink: 0,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
background: '#0c0e15',
|
||||
borderRight: '1px solid #1a1d2a',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
color: '#c8cfe0',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '14px 16px 12px',
|
||||
borderBottom: '1px solid #1a1d2a',
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, letterSpacing: '-0.3px' }}>⚡ Route Viewer</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '14px 14px', overflowY: 'auto', flex: 1 }}>
|
||||
|
||||
{/* Search */}
|
||||
<Section label="SEARCH">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="handler or path…"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: '#12141e', border: '1px solid #1e2235',
|
||||
borderRadius: 5, padding: '6px 9px',
|
||||
color: '#c8cfe0', fontSize: 12, fontFamily: 'monospace',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<div style={{ fontSize: 11, color: '#4a5370', marginTop: 5 }}>
|
||||
{nodes.length} node{nodes.length !== 1 ? 's' : ''} visible
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Lambda toggle */}
|
||||
<Section label="DISPLAY">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 9, cursor: 'pointer', fontSize: 12 }}>
|
||||
<input type="checkbox" checked={showLambdas}
|
||||
onChange={e => setShowLambdas(e.target.checked)}
|
||||
style={{ cursor: 'pointer', accentColor: '#3b82f6' }}
|
||||
/>
|
||||
<span style={{ color: '#8892a4' }}>Show lambda handlers</span>
|
||||
</label>
|
||||
<div style={{ fontSize: 11, color: '#343b54', marginTop: 4, paddingLeft: 21 }}>
|
||||
{lambdaCount} lambda{lambdaCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Stats */}
|
||||
<Section label="STATS">
|
||||
{[
|
||||
['Routes', stats?.routes || 0],
|
||||
['Handlers', handlerCount],
|
||||
['Lambdas', lambdaCount],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} style={{
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
fontSize: 12, marginBottom: 5,
|
||||
}}>
|
||||
<span style={{ color: '#4a5370' }}>{k}</span>
|
||||
<span style={{ color: '#e2e8f0', fontWeight: 600, fontFamily: 'monospace' }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
{/* Legend */}
|
||||
<Section label="LEGEND">
|
||||
{[
|
||||
{ dot: '#3b82f6', border: '#3b82f6', label: 'Handler' },
|
||||
{ dot: '#ef4444', border: '#ef4444', label: 'Abstract' },
|
||||
{ dot: '#f6ad55', border: '#f6ad55', label: 'Middleware' },
|
||||
].map(({ dot, label }) => (
|
||||
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ width: 9, height: 9, borderRadius: 2, background: dot, flexShrink: 0, opacity: 0.8 }} />
|
||||
<span style={{ fontSize: 12, color: '#4a5370' }}>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
{/* Tips */}
|
||||
<div style={{ fontSize: 11, color: '#2d3347', lineHeight: 1.65, marginTop: 4 }}>
|
||||
Hover a node to trace its ancestry.<br />
|
||||
Search filters nodes + parents.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export button */}
|
||||
<div style={{ padding: '12px 14px', borderTop: '1px solid #1a1d2a' }}>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || loading}
|
||||
style={{
|
||||
width: '100%', padding: '8px 0',
|
||||
background: exporting ? '#1e2235' : '#12141e',
|
||||
border: '1px solid #1e2235',
|
||||
borderRadius: 6, color: exporting ? '#4a5370' : '#8892a4',
|
||||
fontSize: 12, cursor: exporting ? 'default' : 'pointer',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
transition: 'all 0.15s',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
{exporting ? '⏳ Exporting…' : '⬇ Export PNG'}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Graph area ────────────────────────────────────────────────────── */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative' }}>
|
||||
|
||||
{/* Topbar */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
padding: '9px 18px',
|
||||
background: '#0c0e15',
|
||||
borderBottom: '1px solid #1a1d2a',
|
||||
flexShrink: 0, zIndex: 10,
|
||||
}}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#e2e8f0', letterSpacing: '-0.2px' }}>
|
||||
Flash Route Graph
|
||||
</span>
|
||||
<span style={{
|
||||
marginLeft: 'auto', display: 'flex', gap: 18,
|
||||
fontSize: 11, color: '#2d3347', fontFamily: 'system-ui',
|
||||
}}>
|
||||
{[['#3b82f6','handler'],['#ef4444','abstract'],['#f6ad55','middleware']].map(([c,l]) => (
|
||||
<span key={l} style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: c, display: 'inline-block', opacity: 0.8 }} />
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <div className="center muted">Loading…</div>}
|
||||
{error && <div className="center error">Error: {error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<ReactFlowProvider>
|
||||
<FlowContent
|
||||
styledNodes={styledNodes}
|
||||
styledEdges={styledEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeMouseEnter={onNodeMouseEnter}
|
||||
onNodeMouseLeave={onNodeMouseLeave}
|
||||
showLambdas={showLambdas}
|
||||
searchQuery={searchQuery}
|
||||
exportRef={exportRef}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Section helper ─────────────────────────────────────────────────────────────
|
||||
function Section({ label, children }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div style={{
|
||||
fontSize: 9, fontWeight: 700, letterSpacing: 1,
|
||||
color: '#272d42', marginBottom: 8, fontFamily: 'monospace',
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #0f1117; color: #e2e8f0; font-family: 'Inter', system-ui, sans-serif; }
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Custom recursive tree layout — Left to Right.
|
||||
*
|
||||
* Each node is centred vertically relative to its children's block.
|
||||
* Siblings are packed tightly; different root trees are separated by ROOT_GAP.
|
||||
* Lambda handlers are placed in a compact grid below the main tree.
|
||||
*
|
||||
* This avoids Dagre's global ranking which puts all siblings in a single
|
||||
* long tower regardless of the number of nodes.
|
||||
*/
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
const RANK_GAP = 60 // horizontal gap between a node's right edge and its children's left edge
|
||||
const SIBLING_GAP = 12 // vertical gap between siblings that belong to the same parent
|
||||
const ROOT_GAP = 48 // vertical gap between independent subtrees (different roots)
|
||||
const MARGIN_X = 60
|
||||
const MARGIN_Y = 60
|
||||
|
||||
const ABSTRACT_W = 160
|
||||
const ABSTRACT_H = 50
|
||||
const CONCRETE_W = 260
|
||||
const LAMBDA_W = 230
|
||||
const LAMBDA_H = 80
|
||||
|
||||
// ── Node sizing ──────────────────────────────────────────────────────────────
|
||||
function nodeWidth(node) {
|
||||
return node.data?.isAbstract ? ABSTRACT_W : CONCRETE_W
|
||||
}
|
||||
|
||||
function nodeHeight(node) {
|
||||
if (node.data?.isAbstract) return ABSTRACT_H
|
||||
const routes = node.data?.routes?.length || 1
|
||||
const hasMw = (node.data?.middleware?.length || 0) > 0
|
||||
// header(44) + divider(9) + routes*22 + [mw row 22] + padding(20)
|
||||
return 44 + 9 + routes * 22 + (hasMw ? 22 : 0) + 20
|
||||
}
|
||||
|
||||
// ── Subtree height (recursive) ────────────────────────────────────────────────
|
||||
function subtreeHeight(nodeId, childrenMap, nodeById) {
|
||||
const kids = childrenMap.get(nodeId) || []
|
||||
const selfH = nodeHeight(nodeById[nodeId])
|
||||
if (!kids.length) return selfH
|
||||
|
||||
const kidsH = kids.reduce((acc, id, i) => {
|
||||
return acc + subtreeHeight(id, childrenMap, nodeById) + (i > 0 ? SIBLING_GAP : 0)
|
||||
}, 0)
|
||||
|
||||
return Math.max(selfH, kidsH)
|
||||
}
|
||||
|
||||
// ── Place a node and its subtree ──────────────────────────────────────────────
|
||||
function placeNode(nodeId, x, y, childrenMap, nodeById, positions) {
|
||||
const node = nodeById[nodeId]
|
||||
const kids = childrenMap.get(nodeId) || []
|
||||
const selfH = nodeHeight(node)
|
||||
const selfW = nodeWidth(node)
|
||||
const totalH = subtreeHeight(nodeId, childrenMap, nodeById)
|
||||
|
||||
// Centre this node within the height its subtree occupies
|
||||
positions.set(nodeId, { x, y: y + (totalH - selfH) / 2 })
|
||||
|
||||
if (kids.length) {
|
||||
const childX = x + selfW + RANK_GAP
|
||||
let childY = y
|
||||
kids.forEach(kidId => {
|
||||
const kidH = subtreeHeight(kidId, childrenMap, nodeById)
|
||||
placeNode(kidId, childX, childY, childrenMap, nodeById, positions)
|
||||
childY += kidH + SIBLING_GAP
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main export ───────────────────────────────────────────────────────────────
|
||||
export function layoutGraph(nodes, edges) {
|
||||
// Separate lambdas from class-based handlers
|
||||
const lambdas = nodes.filter(n => n.data?.isLambda)
|
||||
const regulars = nodes.filter(n => !n.data?.isLambda)
|
||||
|
||||
const nodeById = Object.fromEntries(regulars.map(n => [n.id, n]))
|
||||
const childrenMap = new Map() // parentId → [childId, ...]
|
||||
const parentSet = new Set() // ids that have a parent
|
||||
|
||||
edges.forEach(e => {
|
||||
if (e.edgeType !== 'extends') return
|
||||
if (!childrenMap.has(e.source)) childrenMap.set(e.source, [])
|
||||
childrenMap.get(e.source).push(e.target)
|
||||
parentSet.add(e.target)
|
||||
})
|
||||
|
||||
// Roots = regular nodes with no incoming extends edge
|
||||
const roots = regulars.filter(n => !parentSet.has(n.id))
|
||||
|
||||
// Layout each root subtree
|
||||
const positions = new Map()
|
||||
let curY = MARGIN_Y
|
||||
|
||||
roots.forEach(root => {
|
||||
const treeH = subtreeHeight(root.id, childrenMap, nodeById)
|
||||
placeNode(root.id, MARGIN_X, curY, childrenMap, nodeById, positions)
|
||||
curY += treeH + ROOT_GAP
|
||||
})
|
||||
|
||||
// Apply positions
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
let minY = Infinity
|
||||
|
||||
const layoutedNodes = regulars.map(node => {
|
||||
const pos = positions.get(node.id) || { x: MARGIN_X, y: MARGIN_Y }
|
||||
const r = pos.x + nodeWidth(node)
|
||||
const b = pos.y + nodeHeight(node)
|
||||
if (r > maxX) maxX = r
|
||||
if (b > maxY) maxY = b
|
||||
if (pos.y < minY) minY = pos.y
|
||||
return { ...node, position: pos }
|
||||
})
|
||||
|
||||
// ── Lambda grid — centred below the main tree ────────────────────────────
|
||||
if (lambdas.length) {
|
||||
const treeWidth = maxX - MARGIN_X
|
||||
const cols = Math.min(4, Math.max(2, Math.ceil(Math.sqrt(lambdas.length))))
|
||||
const colWidth = LAMBDA_W + 30
|
||||
const rowHeight = LAMBDA_H + 16
|
||||
const gridWidth = cols * colWidth - 30
|
||||
const gridStartX = MARGIN_X + Math.max(0, (treeWidth - gridWidth) / 2)
|
||||
const gridStartY = maxY + 80
|
||||
|
||||
lambdas.forEach((node, i) => {
|
||||
layoutedNodes.push({
|
||||
...node,
|
||||
position: {
|
||||
x: gridStartX + (i % cols) * colWidth,
|
||||
y: gridStartY + Math.floor(i / cols) * rowHeight,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ── Edges ────────────────────────────────────────────────────────────────
|
||||
const layoutedEdges = edges
|
||||
.filter(e => e.edgeType === 'extends')
|
||||
.map((edge, idx) => ({
|
||||
...edge,
|
||||
id: edge.id || `ext-${idx}`,
|
||||
type: 'bezier',
|
||||
pathOptions: { curvature: 0.35 },
|
||||
style: { stroke: '#2e3347', strokeWidth: 1.5 },
|
||||
markerEnd: { type: 'arrowclosed', width: 11, height: 11, color: '#2e3347' },
|
||||
animated: false,
|
||||
}))
|
||||
|
||||
return { nodes: layoutedNodes, edges: layoutedEdges }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
|
||||
/**
|
||||
* Abstract handler node — represents a handler in the inheritance chain
|
||||
* that is not directly bound to a route. Minimal styling, emphasizes the hierarchy.
|
||||
*/
|
||||
export default function AbstractHandlerNode({ data }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: '#13161f',
|
||||
border: '1px solid #2a2f42',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
minWidth: 160,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
opacity: 0.8,
|
||||
}}>
|
||||
{/* Extends from parent handler */}
|
||||
<Handle type="target" position={Position.Top}
|
||||
style={{ background: '#2a2f42' }} />
|
||||
{/* Extends to child handler */}
|
||||
<Handle type="source" position={Position.Bottom}
|
||||
style={{ background: '#2a2f42' }} />
|
||||
|
||||
<div style={{
|
||||
fontSize: 10, color: '#4a5568', fontWeight: 700, letterSpacing: 0.5,
|
||||
marginBottom: 3,
|
||||
}}>
|
||||
ABSTRACT
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 12, fontWeight: 500, color: '#6b7694',
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
{data.name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
|
||||
const METHOD_COLORS = {
|
||||
GET: { bg: '#0d4429', color: '#4ade80' },
|
||||
POST: { bg: '#172554', color: '#60a5fa' },
|
||||
PUT: { bg: '#451a03', color: '#fb923c' },
|
||||
PATCH: { bg: '#2e1065', color: '#c084fc' },
|
||||
DELETE: { bg: '#450a0a', color: '#f87171' },
|
||||
OPTIONS: { bg: '#1c1917', color: '#a8a29e' },
|
||||
HEAD: { bg: '#1c1917', color: '#a8a29e' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete handler node — represents a handler that is bound to one or more routes.
|
||||
* Shows handler name, HTTP method + path, and middleware stack as inline badges.
|
||||
*/
|
||||
export default function ConcreteHandlerNode({ data }) {
|
||||
const method = data.methods?.[0] || ''
|
||||
const path = data.paths?.[0] || ''
|
||||
const m = METHOD_COLORS[method] || METHOD_COLORS.OPTIONS
|
||||
const pathHtml = path.replace(/\{([^}]+)\}/g, '<span style="color:#a78bfa">{$1}</span>')
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#1a1d27',
|
||||
border: '1px solid #3b82f6',
|
||||
borderRadius: 8,
|
||||
padding: '10px 14px',
|
||||
minWidth: 240,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
}}>
|
||||
{/* Extends from parent handler */}
|
||||
<Handle type="target" position={Position.Top}
|
||||
style={{ background: '#2e3347' }} />
|
||||
{/* Extends to child handler (if any) */}
|
||||
<Handle type="source" position={Position.Bottom}
|
||||
style={{ background: '#2e3347' }} />
|
||||
|
||||
{/* Handler class name */}
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<span style={{
|
||||
fontSize: 10, color: '#60a5fa', fontWeight: 700, letterSpacing: 0.5,
|
||||
}}>
|
||||
HANDLER
|
||||
</span>
|
||||
<div style={{
|
||||
fontSize: 13, fontWeight: 700, color: '#e2e8f0',
|
||||
fontFamily: 'monospace', marginTop: 2,
|
||||
}}>
|
||||
{data.handlerName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ height: 1, background: '#2e3347', marginBottom: 6 }} />
|
||||
|
||||
{/* Method badge + path */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<span style={{
|
||||
background: m.bg, color: m.color,
|
||||
fontSize: 10, fontWeight: 700, padding: '2px 7px',
|
||||
borderRadius: 4, fontFamily: 'monospace', flexShrink: 0,
|
||||
}}>
|
||||
{method}
|
||||
</span>
|
||||
<span
|
||||
style={{ fontSize: 11, fontFamily: 'monospace', color: '#8892a4' }}
|
||||
dangerouslySetInnerHTML={{ __html: pathHtml }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Middleware badges */}
|
||||
{data.middleware && data.middleware.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{data.middleware.map(mw => (
|
||||
<span key={mw} style={{
|
||||
background: '#1f1a0e',
|
||||
color: '#f6ad55',
|
||||
fontSize: 9, fontWeight: 600, padding: '2px 6px',
|
||||
borderRadius: 3, fontFamily: 'monospace', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{mw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
|
||||
const METHOD_COLORS = {
|
||||
GET: { bg: '#0d4429', color: '#4ade80' },
|
||||
POST: { bg: '#172554', color: '#60a5fa' },
|
||||
PUT: { bg: '#451a03', color: '#fb923c' },
|
||||
PATCH: { bg: '#2e1065', color: '#c084fc' },
|
||||
DELETE: { bg: '#450a0a', color: '#f87171' },
|
||||
OPTIONS: { bg: '#1c1917', color: '#a8a29e' },
|
||||
HEAD: { bg: '#1c1917', color: '#a8a29e' },
|
||||
}
|
||||
|
||||
// Handles for LR layout: parent flows in from the LEFT, children exit to the RIGHT
|
||||
const TARGET_HANDLE = <Handle type="target" position={Position.Left}
|
||||
style={{ left: 0, top: '50%', transform: 'translateY(-50%)' }} />
|
||||
const SOURCE_HANDLE = <Handle type="source" position={Position.Right}
|
||||
style={{ right: 0, top: '50%', transform: 'translateY(-50%)' }} />
|
||||
|
||||
/**
|
||||
* Unified handler node — three modes: ABSTRACT, CONCRETE, LAMBDA.
|
||||
* Handles are Left (in) / Right (out) for Left-to-Right DAG layout.
|
||||
*/
|
||||
export default function HandlerNode({ data, selected }) {
|
||||
|
||||
// ── ABSTRACT ──────────────────────────────────────────────────────────
|
||||
if (data.isAbstract) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'rgba(239,68,68,0.05)',
|
||||
border: selected ? '2px solid #60a5fa' : '1px solid #ef4444',
|
||||
borderRadius: 8,
|
||||
padding: '8px 14px',
|
||||
width: 160,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
{TARGET_HANDLE}
|
||||
{SOURCE_HANDLE}
|
||||
<div style={{ fontSize: 9, color: '#ef4444', fontWeight: 700, letterSpacing: 0.5, marginBottom: 3 }}>
|
||||
ABSTRACT
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#e2e8f0', fontFamily: 'monospace' }}>
|
||||
{data.name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── LAMBDA ────────────────────────────────────────────────────────────
|
||||
if (data.isLambda) {
|
||||
const method = data.method || 'GET'
|
||||
const path = data.path || '/'
|
||||
const m = METHOD_COLORS[method] || METHOD_COLORS.OPTIONS
|
||||
const pathHtml = path.replace(/\{([^}]+)\}/g, '<span style="color:#a78bfa">{$1}</span>')
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#1a1d27',
|
||||
border: selected ? '2px solid #60a5fa' : '1px solid #3b82f6',
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
width: 230,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
{TARGET_HANDLE}
|
||||
{SOURCE_HANDLE}
|
||||
<div style={{ fontSize: 9, color: '#60a5fa', fontWeight: 700, letterSpacing: 0.5, marginBottom: 7 }}>
|
||||
LAMBDA
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{
|
||||
background: m.bg, color: m.color,
|
||||
fontSize: 9, fontWeight: 700, padding: '2px 5px',
|
||||
borderRadius: 3, fontFamily: 'monospace', flexShrink: 0,
|
||||
}}>
|
||||
{method}
|
||||
</span>
|
||||
<span
|
||||
style={{ fontSize: 10, fontFamily: 'monospace', color: '#8892a4' }}
|
||||
dangerouslySetInnerHTML={{ __html: pathHtml }}
|
||||
/>
|
||||
</div>
|
||||
{data.middleware?.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', marginTop: 7 }}>
|
||||
{data.middleware.map(mw => (
|
||||
<span key={mw} style={{
|
||||
background: '#1f1a0e', color: '#f6ad55',
|
||||
fontSize: 8, fontWeight: 600, padding: '1px 4px',
|
||||
borderRadius: 2, fontFamily: 'monospace',
|
||||
}}>{mw}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── CONCRETE ──────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div style={{
|
||||
background: '#1a1d27',
|
||||
border: selected ? '2px solid #60a5fa' : '1px solid #3b82f6',
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
width: 260,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
{TARGET_HANDLE}
|
||||
{SOURCE_HANDLE}
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 9, color: '#60a5fa', fontWeight: 700, letterSpacing: 0.5, marginBottom: 2 }}>
|
||||
HANDLER
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: '#e2e8f0', fontFamily: 'monospace' }}>
|
||||
{data.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: '#2e3347', marginBottom: 8 }} />
|
||||
|
||||
{/* Routes */}
|
||||
<div style={{ marginBottom: data.middleware?.length > 0 ? 8 : 0 }}>
|
||||
{data.routes?.map((route, i) => {
|
||||
const m = METHOD_COLORS[route.method] || METHOD_COLORS.OPTIONS
|
||||
const pathHtml = (route.path || '/').replace(
|
||||
/\{([^}]+)\}/g,
|
||||
'<span style="color:#a78bfa">{$1}</span>'
|
||||
)
|
||||
return (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
marginBottom: i < data.routes.length - 1 ? 5 : 0,
|
||||
}}>
|
||||
<span style={{
|
||||
background: m.bg, color: m.color,
|
||||
fontSize: 8, fontWeight: 700, padding: '2px 5px',
|
||||
borderRadius: 3, fontFamily: 'monospace', flexShrink: 0,
|
||||
}}>
|
||||
{route.method}
|
||||
</span>
|
||||
<span
|
||||
style={{ fontSize: 10, fontFamily: 'monospace', color: '#8892a4' }}
|
||||
dangerouslySetInnerHTML={{ __html: pathHtml }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Middleware */}
|
||||
{data.middleware?.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
||||
{data.middleware.map(mw => (
|
||||
<span key={mw} style={{
|
||||
background: '#1f1a0e', color: '#f6ad55',
|
||||
fontSize: 8, fontWeight: 600, padding: '1px 4px',
|
||||
borderRadius: 2, fontFamily: 'monospace',
|
||||
}}>{mw}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
|
||||
export default function MiddlewareNode({ data }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: '#1f1a0e',
|
||||
border: '1px solid #92400e',
|
||||
borderRadius: 8,
|
||||
padding: '7px 14px',
|
||||
minWidth: 150,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
}}>
|
||||
{/* shared node: sends applies edges to all routes that use this MW */}
|
||||
<Handle id="out-right" type="source" position={Position.Right}
|
||||
style={{ background: '#92400e' }} />
|
||||
|
||||
<div style={{ fontSize: 10, color: '#f6ad55', fontWeight: 700, letterSpacing: 0.5, marginBottom: 2 }}>
|
||||
MIDDLEWARE
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#fde68a', fontFamily: 'monospace' }}>
|
||||
{data.name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Handle, Position } from '@xyflow/react'
|
||||
|
||||
const METHOD_COLORS = {
|
||||
GET: { bg: '#0d4429', color: '#4ade80' },
|
||||
POST: { bg: '#172554', color: '#60a5fa' },
|
||||
PUT: { bg: '#451a03', color: '#fb923c' },
|
||||
PATCH: { bg: '#2e1065', color: '#c084fc' },
|
||||
DELETE: { bg: '#450a0a', color: '#f87171' },
|
||||
OPTIONS: { bg: '#1c1917', color: '#a8a29e' },
|
||||
HEAD: { bg: '#1c1917', color: '#a8a29e' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined entry node — shows the concrete handler name above
|
||||
* and the HTTP method + path below. Replaces the old split
|
||||
* Route → Handler[0] pair.
|
||||
*/
|
||||
export default function RouteNode({ data }) {
|
||||
const m = METHOD_COLORS[data.method] ?? METHOD_COLORS.OPTIONS
|
||||
const path = data.path.replace(/\{([^}]+)\}/g, '<span style="color:#a78bfa">{$1}</span>')
|
||||
const isSimple = !data.handlerName || data.handlerName === 'Simple Handler'
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#1a1d27',
|
||||
border: '1px solid #3b82f6',
|
||||
borderRadius: 8,
|
||||
padding: '8px 14px',
|
||||
minWidth: 200,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
}}>
|
||||
{/* receives MW → entry "applies" edges */}
|
||||
<Handle id="in-left" type="target" position={Position.Left}
|
||||
style={{ background: '#2e3347' }} />
|
||||
{/* sends extends edge to abstract parent chain */}
|
||||
<Handle id="out-bottom" type="source" position={Position.Bottom}
|
||||
style={{ background: '#2e3347' }} />
|
||||
|
||||
{/* Handler class name */}
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 10, color: '#60a5fa', fontWeight: 700, letterSpacing: 0.5 }}>
|
||||
{isSimple ? 'HANDLER' : 'HANDLER'}
|
||||
</span>
|
||||
<div style={{
|
||||
fontSize: 13, fontWeight: 700, color: '#e2e8f0',
|
||||
fontFamily: 'monospace', marginTop: 1,
|
||||
}}>
|
||||
{isSimple ? 'Simple Handler' : data.handlerName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ height: 1, background: '#2e3347', marginBottom: 6 }} />
|
||||
|
||||
{/* Method badge + path */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{
|
||||
background: m.bg, color: m.color,
|
||||
fontSize: 10, fontWeight: 700, padding: '2px 7px',
|
||||
borderRadius: 4, fontFamily: 'monospace', flexShrink: 0,
|
||||
}}>{data.method}</span>
|
||||
<span
|
||||
style={{ fontSize: 12, fontFamily: 'monospace', color: '#8892a4' }}
|
||||
dangerouslySetInnerHTML={{ __html: path }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Section header — no edges, purely positional grouping.
|
||||
* Styled as a slim label bar above the router's route group.
|
||||
*/
|
||||
export default function RouterNode({ data }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(90deg, #16122a 0%, #1a1d27 100%)',
|
||||
border: '1px solid #3d2f7a',
|
||||
borderLeft: '3px solid #7c6af7',
|
||||
borderRadius: 6,
|
||||
padding: '6px 14px',
|
||||
minWidth: 240,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
}}>
|
||||
<span style={{ fontSize: 10, color: '#7c6af7', fontWeight: 700, letterSpacing: 1, flexShrink: 0 }}>
|
||||
ROUTER
|
||||
</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: '#e2e8f0', fontFamily: 'monospace' }}>
|
||||
{data.namespace}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#4a5568', marginLeft: 'auto' }}>
|
||||
{data.routerType} · {data.routeCount}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Canonical Graph Normalization.
|
||||
*
|
||||
* Transform raw route/handler nodes into a class-centric DAG where:
|
||||
* - Each handler class appears exactly ONCE
|
||||
* - Inheritance is the primary relationship
|
||||
* - Routes are attached to leaf (concrete) handlers
|
||||
* - Middleware is extracted and attached to handlers
|
||||
* - Lambda routes (no handler) become isolated leaf nodes
|
||||
*/
|
||||
|
||||
export function normalizeGraph(rawNodes, rawEdges) {
|
||||
const byId = Object.fromEntries(rawNodes.map(n => [n.id, n]))
|
||||
const ofType = t => rawNodes.filter(n => n.type === t)
|
||||
|
||||
const routeRaw = ofType('route')
|
||||
const handlerRaw = ofType('handler')
|
||||
|
||||
// ── Extract metadata per route ──────────────────────────────────────────
|
||||
const routeMetadata = new Map() // routeId → {method, path, middleware: []}
|
||||
|
||||
routeRaw.forEach(route => {
|
||||
routeMetadata.set(route.id, {
|
||||
method: route.data?.method || 'GET',
|
||||
path: route.data?.path || '/',
|
||||
middleware: [],
|
||||
})
|
||||
})
|
||||
|
||||
// ── Extract middleware per route ───────────────────────────────────────
|
||||
const wrapsEdges = rawEdges.filter(e => e.edgeType === 'wraps')
|
||||
const wrapsOf = new Map() // wrappedNode → wrapperNode
|
||||
wrapsEdges.forEach(e => wrapsOf.set(e.target, e.source))
|
||||
|
||||
routeRaw.forEach(route => {
|
||||
const middleware = []
|
||||
let cur = wrapsOf.get(route.id)
|
||||
while (cur && byId[cur]?.type === 'middleware') {
|
||||
middleware.unshift(byId[cur].data.name)
|
||||
cur = wrapsOf.get(cur)
|
||||
}
|
||||
if (middleware.length) {
|
||||
routeMetadata.get(route.id).middleware = middleware
|
||||
}
|
||||
})
|
||||
|
||||
// ── Build inheritance map ──────────────────────────────────────────────
|
||||
// handlerClassName → parentClassName
|
||||
const inheritanceMap = new Map()
|
||||
const extendsEdges = rawEdges.filter(e => e.edgeType === 'extends')
|
||||
|
||||
extendsEdges.forEach(e => {
|
||||
const childNode = byId[e.source]
|
||||
const parentNode = byId[e.target]
|
||||
if (childNode?.data?.name && parentNode?.data?.name) {
|
||||
inheritanceMap.set(childNode.data.name, parentNode.data.name)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Build handler chains per route ─────────────────────────────────────
|
||||
const handlesEdges = rawEdges.filter(e => e.edgeType === 'handles')
|
||||
const routeToChain = new Map() // routeId → [className, parentClassName, ...]
|
||||
|
||||
handlesEdges.forEach(e => {
|
||||
const handlerNode = byId[e.target]
|
||||
if (handlerNode?.data?.name) {
|
||||
const chain = []
|
||||
let cur = handlerNode.data.name
|
||||
while (cur) {
|
||||
chain.push(cur)
|
||||
cur = inheritanceMap.get(cur)
|
||||
}
|
||||
routeToChain.set(e.source, chain)
|
||||
}
|
||||
})
|
||||
|
||||
// Ensure ALL routes are in the chain map (routes without handlers get empty chain)
|
||||
routeRaw.forEach(route => {
|
||||
if (!routeToChain.has(route.id)) {
|
||||
routeToChain.set(route.id, [])
|
||||
}
|
||||
})
|
||||
|
||||
// ── Collect all unique handler class names ──────────────────────────────
|
||||
const allClassNames = new Set()
|
||||
routeToChain.forEach(chain => chain.forEach(name => allClassNames.add(name)))
|
||||
|
||||
// ── Create canonical handler nodes ────────────────────────────────────
|
||||
const canonicalHandlers = new Map() // class:ClassName → {id, name, isAbstract, routes[], parentId, isLambda}
|
||||
|
||||
;[...allClassNames].forEach(className => {
|
||||
const id = 'class:' + className
|
||||
canonicalHandlers.set(id, {
|
||||
id,
|
||||
name: className,
|
||||
isAbstract: true, // will be marked false if used as concrete
|
||||
routes: [],
|
||||
parentId: null,
|
||||
isLambda: false,
|
||||
})
|
||||
})
|
||||
|
||||
// ── Wire up inheritance ────────────────────────────────────────────────
|
||||
inheritanceMap.forEach((parentClassName, childClassName) => {
|
||||
const childId = 'class:' + childClassName
|
||||
const parentId = 'class:' + parentClassName
|
||||
if (canonicalHandlers.has(childId) && canonicalHandlers.has(parentId)) {
|
||||
canonicalHandlers.get(childId).parentId = parentId
|
||||
}
|
||||
})
|
||||
|
||||
// ── Attach routes to concrete handlers ──────────────────────────────────
|
||||
routeToChain.forEach((chain, routeId) => {
|
||||
if (chain.length === 0) {
|
||||
// Lambda: no handler
|
||||
const meta = routeMetadata.get(routeId)
|
||||
const lambdaId = 'class:Lambda:' + meta.method + ':' + encodeURIComponent(meta.path)
|
||||
canonicalHandlers.set(lambdaId, {
|
||||
id: lambdaId,
|
||||
name: 'Lambda Handler',
|
||||
isAbstract: false,
|
||||
isLambda: true,
|
||||
method: meta.method,
|
||||
path: meta.path,
|
||||
routes: [{
|
||||
method: meta.method,
|
||||
path: meta.path,
|
||||
middleware: meta.middleware,
|
||||
}],
|
||||
parentId: null,
|
||||
})
|
||||
} else {
|
||||
// Normal handler chain: mark concrete (first = depth 0)
|
||||
const concreteClassName = chain[0]
|
||||
const concreteId = 'class:' + concreteClassName
|
||||
if (canonicalHandlers.has(concreteId)) {
|
||||
const handler = canonicalHandlers.get(concreteId)
|
||||
handler.isAbstract = false
|
||||
const meta = routeMetadata.get(routeId)
|
||||
handler.routes.push({
|
||||
method: meta.method,
|
||||
path: meta.path,
|
||||
middleware: meta.middleware,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── Build output nodes ────────────────────────────────────────────────
|
||||
const nodes = [...canonicalHandlers.values()].map(handler => ({
|
||||
id: handler.id,
|
||||
type: 'handler',
|
||||
data: {
|
||||
name: handler.name,
|
||||
isAbstract: handler.isAbstract,
|
||||
isLambda: handler.isLambda,
|
||||
method: handler.method,
|
||||
path: handler.path,
|
||||
routes: handler.routes,
|
||||
middleware: handler.routes.length > 0
|
||||
? [...new Set(handler.routes.flatMap(r => r.middleware))]
|
||||
: [],
|
||||
},
|
||||
}))
|
||||
|
||||
// ── Build output edges (deduped extends only) ──────────────────────────
|
||||
const edgeSet = new Set()
|
||||
const edges = []
|
||||
|
||||
canonicalHandlers.forEach((handler, handlerId) => {
|
||||
if (handler.parentId) {
|
||||
const key = handler.parentId + '->' + handlerId
|
||||
if (!edgeSet.has(key)) {
|
||||
edgeSet.add(key)
|
||||
edges.push({
|
||||
id: key,
|
||||
source: handler.parentId,
|
||||
target: handlerId,
|
||||
edgeType: 'extends',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/routeviewer/',
|
||||
build: {
|
||||
outDir: '../src/main/resources/routeviewer',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: 'app.js',
|
||||
chunkFileNames: 'chunk-[hash].js',
|
||||
assetFileNames: 'app.[ext]'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
+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>
|
||||
@@ -17,6 +17,7 @@
|
||||
<module>flash-ext-jackson</module>
|
||||
<module>flash-ext-openapi</module>
|
||||
<module>flash-ext-oidc</module>
|
||||
<module>flash-ext-routeviewer</module>
|
||||
</modules>
|
||||
|
||||
<dependencyManagement>
|
||||
|
||||
Reference in New Issue
Block a user