preparing for a conceptual refactoring...
This commit is contained in:
@@ -49,3 +49,4 @@ nuxt-shadcn-dashboard/
|
|||||||
/docs/
|
/docs/
|
||||||
jmh-result.text
|
jmh-result.text
|
||||||
*.text
|
*.text
|
||||||
|
/flash-extensions/flash-ext-routeviewer/routeviewer-ui/node_modules/
|
||||||
|
|||||||
Generated
+2
@@ -11,6 +11,8 @@
|
|||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/java" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" />
|
||||||
|
|||||||
Generated
+78
-50
@@ -4,12 +4,26 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="enhanced router middleware support; added pre-fused middleware handling and improved handler registration">
|
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="pre-major refactoring + ext api.">
|
||||||
<change afterPath="$PROJECT_DIR$/.idea/jsLibraryMappings.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/.gitignore" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/encodings.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/encodings.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.idea/encodings.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/encodings.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/vcs.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/vcs.xml" afterDir="false" />
|
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/README.md" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonExtension.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/README.md" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/Authenticated.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/Authenticated.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcMiddleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcMiddleware.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/README.md" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiExtension.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash-extensions/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/pom.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServer.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServer.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServer.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServer.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServerConfiguration.java" beforeDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/ExtensionContext.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/ExtensionContext.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashApp.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashApp.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashExtension.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Response.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Response.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/AbstractRouter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/AbstractRouter.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/AbstractRouter.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/AbstractRouter.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerTest.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerTest.java" afterDir="false" />
|
||||||
@@ -30,7 +44,7 @@
|
|||||||
</persistenceIdMap>
|
</persistenceIdMap>
|
||||||
</component>
|
</component>
|
||||||
<component name="EmbeddingIndexingInfo">
|
<component name="EmbeddingIndexingInfo">
|
||||||
<option name="cachedIndexableFilesCount" value="130" />
|
<option name="cachedIndexableFilesCount" value="203" />
|
||||||
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
|
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="FileTemplateManagerImpl">
|
<component name="FileTemplateManagerImpl">
|
||||||
@@ -69,50 +83,50 @@
|
|||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
<option name="showLibraryContents" value="true" />
|
<option name="showLibraryContents" value="true" />
|
||||||
</component>
|
</component>
|
||||||
<component name="PropertiesComponent"><![CDATA[{
|
<component name="PropertiesComponent">{
|
||||||
"keyToString": {
|
"keyToString": {
|
||||||
"Application.ExternalBenchmark (1).executor": "Run",
|
"Application.ExternalBenchmark (1).executor": "Run",
|
||||||
"Application.ExternalBenchmark.executor": "Run",
|
"Application.ExternalBenchmark.executor": "Run",
|
||||||
"Application.Main.executor": "Run",
|
"Application.Main.executor": "Run",
|
||||||
"Application.dev.relism.bench.Main.executor": "Run",
|
"Application.dev.relism.bench.Main.executor": "Run",
|
||||||
"JUnit.RequestParserTest.executor": "Run",
|
"JUnit.RequestParserTest.executor": "Run",
|
||||||
"JUnit.RequestParserTest.headers_caseInsensitive.executor": "Debug",
|
"JUnit.RequestParserTest.headers_caseInsensitive.executor": "Debug",
|
||||||
"Maven.FlashPractice [test].executor": "Run",
|
"Maven.FlashPractice [test].executor": "Run",
|
||||||
"Maven.flash [compile].executor": "Run",
|
"Maven.flash [compile].executor": "Run",
|
||||||
"Maven.flash [install].executor": "Run",
|
"Maven.flash [install].executor": "Run",
|
||||||
"Maven.flash [test].executor": "Run",
|
"Maven.flash [test].executor": "Run",
|
||||||
"Maven.flash-bench [clean].executor": "Run",
|
"Maven.flash-bench [clean].executor": "Run",
|
||||||
"Maven.flash-bench [install].executor": "Run",
|
"Maven.flash-bench [install].executor": "Run",
|
||||||
"Maven.flash-bench [package].executor": "Run",
|
"Maven.flash-bench [package].executor": "Run",
|
||||||
"Maven.flash-bench [validate].executor": "Run",
|
"Maven.flash-bench [validate].executor": "Run",
|
||||||
"Maven.flash-parent [install].executor": "Run",
|
"Maven.flash-parent [install].executor": "Run",
|
||||||
"Maven.flash-parent [package].executor": "Run",
|
"Maven.flash-parent [package].executor": "Run",
|
||||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||||
"RunOnceActivity.MCP Project settings loaded": "true",
|
"RunOnceActivity.MCP Project settings loaded": "true",
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
"SHARE_PROJECT_CONFIGURATION_FILES": "true",
|
"SHARE_PROJECT_CONFIGURATION_FILES": "true",
|
||||||
"git-widget-placeholder": "master",
|
"git-widget-placeholder": "master",
|
||||||
"ignore.virus.scanning.warn.message": "true",
|
"ignore.virus.scanning.warn.message": "true",
|
||||||
"kotlin-language-version-configured": "true",
|
"kotlin-language-version-configured": "true",
|
||||||
"last_opened_file_path": "C:/Users/elorc/Documents/Coding/Java/practice/Flash",
|
"last_opened_file_path": "C:/Users/elorc/Documents/Coding/Java/practice/Flash",
|
||||||
"node.js.detected.package.eslint": "true",
|
"node.js.detected.package.eslint": "true",
|
||||||
"node.js.detected.package.tslint": "true",
|
"node.js.detected.package.tslint": "true",
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
"node.js.selected.package.tslint": "(autodetect)",
|
||||||
"nodejs_package_manager_path": "npm",
|
"nodejs_package_manager_path": "npm",
|
||||||
"npm.build.executor": "Run",
|
"npm.build.executor": "Run",
|
||||||
"onboarding.tips.debug.path": "C:/Users/elorc/Documents/Coding/Java/practice/FlashPractice/flash-bench/src/main/java/dev/relism/Main.java",
|
"onboarding.tips.debug.path": "C:/Users/elorc/Documents/Coding/Java/practice/FlashPractice/flash-bench/src/main/java/dev/relism/Main.java",
|
||||||
"project.structure.last.edited": "Modules",
|
"project.structure.last.edited": "Modules",
|
||||||
"project.structure.proportion": "0.15",
|
"project.structure.proportion": "0.15",
|
||||||
"project.structure.side.proportion": "0.1150748",
|
"project.structure.side.proportion": "0.1150748",
|
||||||
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings",
|
"settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings",
|
||||||
"ts.external.directory.path": "C:\\Users\\elorc\\Documents\\Coding\\Java\\practice\\Flash\\nuxt-shadcn-dashboard\\node_modules\\typescript\\lib",
|
"ts.external.directory.path": "C:\\Users\\elorc\\Documents\\Coding\\Java\\practice\\Flash\\nuxt-shadcn-dashboard\\node_modules\\typescript\\lib",
|
||||||
"vue.rearranger.settings.migration": "true"
|
"vue.rearranger.settings.migration": "true"
|
||||||
}
|
}
|
||||||
}]]></component>
|
}</component>
|
||||||
<component name="RecentsManager">
|
<component name="RecentsManager">
|
||||||
<key name="MoveFile.RECENT_KEYS">
|
<key name="MoveFile.RECENT_KEYS">
|
||||||
<recent name="C:\Users\elorc\Documents\Coding\Java\practice\Flash" />
|
<recent name="C:\Users\elorc\Documents\Coding\Java\practice\Flash" />
|
||||||
@@ -251,7 +265,12 @@
|
|||||||
<workItem from="1774136286683" duration="3816000" />
|
<workItem from="1774136286683" duration="3816000" />
|
||||||
<workItem from="1774187096932" duration="25000" />
|
<workItem from="1774187096932" duration="25000" />
|
||||||
<workItem from="1774458099414" duration="14351000" />
|
<workItem from="1774458099414" duration="14351000" />
|
||||||
<workItem from="1774523863906" duration="4441000" />
|
<workItem from="1774523863906" duration="9762000" />
|
||||||
|
<workItem from="1774555172612" duration="9776000" />
|
||||||
|
<workItem from="1774604979874" duration="16474000" />
|
||||||
|
<workItem from="1774628513273" duration="86000" />
|
||||||
|
<workItem from="1774638461244" duration="5718000" />
|
||||||
|
<workItem from="1774691772785" duration="3801000" />
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00001" summary="Initial">
|
<task id="LOCAL-00001" summary="Initial">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
@@ -309,7 +328,15 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1773952556190</updated>
|
<updated>1773952556190</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="8" />
|
<task id="LOCAL-00008" summary="pre-major refactoring + ext api.">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1774530802482</created>
|
||||||
|
<option name="number" value="00008" />
|
||||||
|
<option name="presentableId" value="LOCAL-00008" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1774530802482</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="9" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -346,7 +373,8 @@
|
|||||||
<MESSAGE value="enhanced HTTP server configuration and response handling; added acceptorThreads, improved header management, and refined error page titles" />
|
<MESSAGE value="enhanced HTTP server configuration and response handling; added acceptorThreads, improved header management, and refined error page titles" />
|
||||||
<MESSAGE value="multipart parsing, request body access, and chunked input stream support" />
|
<MESSAGE value="multipart parsing, request body access, and chunked input stream support" />
|
||||||
<MESSAGE value="enhanced router middleware support; added pre-fused middleware handling and improved handler registration" />
|
<MESSAGE value="enhanced router middleware support; added pre-fused middleware handling and improved handler registration" />
|
||||||
<option name="LAST_COMMIT_MESSAGE" value="enhanced router middleware support; added pre-fused middleware handling and improved handler registration" />
|
<MESSAGE value="pre-major refactoring + ext api." />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="pre-major refactoring + ext api." />
|
||||||
</component>
|
</component>
|
||||||
<component name="XSLT-Support.FileAssociations.UIState">
|
<component name="XSLT-Support.FileAssociations.UIState">
|
||||||
<expand />
|
<expand />
|
||||||
|
|||||||
@@ -1 +1,194 @@
|
|||||||
# Flash
|
# Flash
|
||||||
|
|
||||||
|
A high-performance HTTP/1.1 server library for Java 21, built around virtual threads and a zero-allocation FSM router.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
| Module | Description |
|
||||||
|
|---|---|
|
||||||
|
| `flash` | Core server library — router, request parser, HTTP I/O transport |
|
||||||
|
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
|
||||||
|
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
||||||
|
| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow |
|
||||||
|
| `flash-bench` | Demo harness (OIDC + OpenAPI + Jackson) |
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Java 21+
|
||||||
|
- Maven 3.8+
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```java
|
||||||
|
FlashApp.create(8080)
|
||||||
|
.get("/ping", (req, res) -> "pong")
|
||||||
|
.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
With full configuration:
|
||||||
|
|
||||||
|
```java
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder()
|
||||||
|
.port(8080)
|
||||||
|
.host("0.0.0.0")
|
||||||
|
.maxHeaderBufferSize(65536)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.get("/ping", (req, res) -> "pong")
|
||||||
|
.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Route registration
|
||||||
|
|
||||||
|
### Lambda routes
|
||||||
|
|
||||||
|
```java
|
||||||
|
FlashApp app = FlashApp.create(8080);
|
||||||
|
|
||||||
|
app.get("/hello", (req, res) -> "world");
|
||||||
|
|
||||||
|
app.post("/echo", (req, res) -> {
|
||||||
|
byte[] body = req.body().bytes();
|
||||||
|
return res.status(200).body(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/users/{id}", (req, res) -> {
|
||||||
|
String id = req.pathParam("id");
|
||||||
|
return "user:" + id;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Class-based handlers
|
||||||
|
|
||||||
|
Extend `RequestHandler` (or a subclass like `JacksonHandler`) and annotate with `@Route`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Route(method = HttpMethod.GET, path = "/api/users")
|
||||||
|
public class ListUsers extends JacksonHandler {
|
||||||
|
@Override
|
||||||
|
public Object handle(Request req, Response res) throws Exception {
|
||||||
|
return json(res, List.of("alice", "bob"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register:
|
||||||
|
app.register(new ListUsers());
|
||||||
|
```
|
||||||
|
|
||||||
|
### Middleware
|
||||||
|
|
||||||
|
Apply middleware via `.with()` on the `RouteHandle` returned by any registration call:
|
||||||
|
|
||||||
|
```java
|
||||||
|
Middleware authCheck = next -> (req, res) -> {
|
||||||
|
if (req.header("Authorization") == null)
|
||||||
|
return res.status(401).body("Unauthorized");
|
||||||
|
return next.handle(req, res);
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get("/secure", (req, res) -> "secret data")
|
||||||
|
.with(authCheck);
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple middlewares are composed outermost-first (left-to-right in the call):
|
||||||
|
|
||||||
|
```java
|
||||||
|
app.get("/admin", handler).with(logging, auth, rateLimit);
|
||||||
|
// execution order: logging → auth → rateLimit → handler
|
||||||
|
```
|
||||||
|
|
||||||
|
### Classpath scan
|
||||||
|
|
||||||
|
Scans a package for classes that extend `RequestHandler` and carry `@Route`. Each is
|
||||||
|
instantiated via its public no-arg constructor:
|
||||||
|
|
||||||
|
```java
|
||||||
|
app.scan("dev.example.handlers");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Namespace mounting
|
||||||
|
|
||||||
|
Mount a scoped sub-router under a prefix. All routes registered inside the scope get the
|
||||||
|
prefix prepended automatically. The scope inherits the parent's extension context (annotation
|
||||||
|
processors, services):
|
||||||
|
|
||||||
|
```java
|
||||||
|
app.mount("/api", scope -> {
|
||||||
|
scope.get("/health", (req, res) -> "ok"); // → GET /api/health
|
||||||
|
scope.register(new UserHandler()); // @Route(path="/users") → GET /api/users
|
||||||
|
scope.scan("dev.example.api");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extensions
|
||||||
|
|
||||||
|
Extensions are installed before route registration. Each extension receives the `FlashRegistrar`
|
||||||
|
and `ExtensionContext` — it can register routes, expose services, and register annotation processors.
|
||||||
|
|
||||||
|
```java
|
||||||
|
FlashApp.create(8080)
|
||||||
|
.install(new JacksonExtension())
|
||||||
|
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
|
||||||
|
.install(new OidcExtension(oidcConfig))
|
||||||
|
.register(new MyHandler())
|
||||||
|
.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
See extension-specific READMEs for full details:
|
||||||
|
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
|
||||||
|
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
|
||||||
|
- [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/README.md)
|
||||||
|
|
||||||
|
## Error handlers
|
||||||
|
|
||||||
|
```java
|
||||||
|
app.onNotFound((req, res) -> res.status(404).body("Not found: " + req.path()));
|
||||||
|
|
||||||
|
app.onException((ex, req, res) -> {
|
||||||
|
if (ex instanceof IllegalArgumentException)
|
||||||
|
return res.status(400).body(ex.getMessage());
|
||||||
|
return res.status(500).body("Internal error");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## FlashConfiguration
|
||||||
|
|
||||||
|
| Field | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `port` | — | TCP port to bind |
|
||||||
|
| `host` | `"0.0.0.0"` | Bind address |
|
||||||
|
| `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
ServerSocket.accept()
|
||||||
|
→ RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive
|
||||||
|
→ GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl
|
||||||
|
→ RequestHandler.handle() # user handler; return value sets body
|
||||||
|
→ Request.drain() # consume unread body for keep-alive
|
||||||
|
→ HttpServer writes response # status line, headers, then fixed or chunked body
|
||||||
|
→ loop or close socket # based on Connection header
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`). Java 21 required.
|
||||||
|
- **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation.
|
||||||
|
- **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection.
|
||||||
|
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
|
||||||
|
|
||||||
|
## Build & test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build all modules (skip tests)
|
||||||
|
mvn clean package -DskipTests
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
mvn test
|
||||||
|
|
||||||
|
# Run a single test class
|
||||||
|
mvn test -pl flash -Dtest=RequestParserTest
|
||||||
|
|
||||||
|
# Run the benchmark demo server
|
||||||
|
java -jar flash-bench/target/flash-bench-1.0-SNAPSHOT.jar
|
||||||
|
```
|
||||||
|
|||||||
@@ -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`):
|
Install before any extension that needs JSON (e.g. `flash-ext-openapi`, `flash-ext-oidc`):
|
||||||
|
|
||||||
```java
|
```java
|
||||||
FlashApp.of(new HttpServer(config))
|
FlashApp.create(8080)
|
||||||
.install(new JacksonExtension())
|
.install(new JacksonExtension())
|
||||||
// ... other extensions
|
// ... other extensions
|
||||||
```
|
```
|
||||||
@@ -36,7 +36,7 @@ ObjectMapper mapper = JsonMapper.builder()
|
|||||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
FlashApp.of(new HttpServer(config))
|
FlashApp.create(8080)
|
||||||
.install(new JacksonExtension(mapper));
|
.install(new JacksonExtension(mapper));
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||||
import dev.relism.exceptions.HttpException;
|
import dev.relism.exceptions.HttpException;
|
||||||
import dev.relism.extension.ExtensionContext;
|
import dev.relism.extension.ExtensionContext;
|
||||||
import dev.relism.extension.FlashApp;
|
|
||||||
import dev.relism.extension.FlashExtension;
|
import dev.relism.extension.FlashExtension;
|
||||||
|
import dev.relism.extension.FlashRegistrar;
|
||||||
import dev.relism.http.ContentType;
|
import dev.relism.http.ContentType;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,7 +45,7 @@ public class JacksonExtension implements FlashExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void install(FlashApp app, ExtensionContext ctx) {
|
public void install(FlashRegistrar app, ExtensionContext ctx) {
|
||||||
ctx.provide(ObjectMapper.class, mapper);
|
ctx.provide(ObjectMapper.class, mapper);
|
||||||
JacksonHandler.mapper = mapper;
|
JacksonHandler.mapper = mapper;
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```java
|
```java
|
||||||
FlashApp.of(new HttpServer(config))
|
FlashApp.create(8080)
|
||||||
.install(new JacksonExtension())
|
.install(new JacksonExtension())
|
||||||
.install(new OpenApiExtension(...)) // optional — enables Swagger security
|
.install(new OpenApiExtension(...)) // optional — enables Swagger security
|
||||||
.install(new OidcExtension(
|
.install(new OidcExtension(
|
||||||
@@ -144,17 +144,17 @@ OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
|
|||||||
app.get("/api/me", (req, res) -> {
|
app.get("/api/me", (req, res) -> {
|
||||||
OidcUser u = ClaimsHolder.user(); // never null here
|
OidcUser u = ClaimsHolder.user(); // never null here
|
||||||
return Map.of("sub", u.sub(), "email", u.email());
|
return Map.of("sub", u.sub(), "email", u.email());
|
||||||
}, oidc.protect());
|
}).with(oidc.protect());
|
||||||
|
|
||||||
// Authentication + role check
|
// Authentication + role check
|
||||||
app.delete("/api/admin/users/{id}", (req, res) -> {
|
app.delete("/api/admin/users/{id}", (req, res) -> {
|
||||||
OidcUser u = ClaimsHolder.user();
|
OidcUser u = ClaimsHolder.user();
|
||||||
// ...
|
// ...
|
||||||
}, oidc.requireRole("admin"));
|
}).with(oidc.requireRole("admin"));
|
||||||
|
|
||||||
// Multiple roles (OR): passes if user holds any one of them
|
// Multiple roles (OR): passes if user holds any one of them
|
||||||
app.get("/api/reports", (req, res) -> { ... },
|
app.get("/api/reports", (req, res) -> { ... })
|
||||||
oidc.requireRole("admin", "reports-viewer"));
|
.with(oidc.requireRole("admin", "reports-viewer"));
|
||||||
```
|
```
|
||||||
|
|
||||||
`oidc.protect()` / `oidc.requireRole(...)` return a `Middleware` — a composable
|
`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));
|
app.install(new OidcExtension(tenantB));
|
||||||
OidcMiddleware mwB = app.ctx().require(OidcMiddleware.class); // tenantB's middleware
|
OidcMiddleware mwB = app.ctx().require(OidcMiddleware.class); // tenantB's middleware
|
||||||
|
|
||||||
app.get("/a/dashboard", (req, res) -> { ... }, mwA.protect());
|
app.get("/a/dashboard", (req, res) -> { ... }).with(mwA.protect());
|
||||||
app.get("/b/dashboard", (req, res) -> { ... }, mwB.protect());
|
app.get("/b/dashboard", (req, res) -> { ... }).with(mwB.protect());
|
||||||
```
|
```
|
||||||
|
|
||||||
Class-based handlers annotated with `@Authenticated` / `@RolesAllowed` get the last
|
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>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
|
* <pre>{@code
|
||||||
|
* // Hard auth — redirects / 401 when unauthenticated:
|
||||||
* @Route(method = HttpMethod.GET, path = "/api/profile")
|
* @Route(method = HttpMethod.GET, path = "/api/profile")
|
||||||
* @Authenticated
|
* @Authenticated
|
||||||
* public class GetProfile extends JacksonHandler { ... }
|
* 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>
|
* }</pre>
|
||||||
*/
|
*/
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
@Target(ElementType.TYPE)
|
@Target(ElementType.TYPE)
|
||||||
public @interface Authenticated {
|
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;
|
package dev.relism.ext.oidc;
|
||||||
|
|
||||||
import dev.relism.extension.ExtensionContext;
|
import dev.relism.extension.ExtensionContext;
|
||||||
import dev.relism.extension.FlashApp;
|
|
||||||
import dev.relism.extension.FlashExtension;
|
import dev.relism.extension.FlashExtension;
|
||||||
|
import dev.relism.extension.FlashRegistrar;
|
||||||
|
|
||||||
import javax.net.ssl.SSLContext;
|
import javax.net.ssl.SSLContext;
|
||||||
import javax.net.ssl.TrustManager;
|
import javax.net.ssl.TrustManager;
|
||||||
@@ -59,7 +59,7 @@ public class OidcExtension implements FlashExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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)
|
// 1. Build the shared HttpClient (optionally with TLS verification disabled)
|
||||||
HttpClient http = buildHttpClient(config);
|
HttpClient http = buildHttpClient(config);
|
||||||
@@ -116,7 +116,7 @@ public class OidcExtension implements FlashExtension {
|
|||||||
+ "&code_challenge=" + challenge
|
+ "&code_challenge=" + challenge
|
||||||
+ "&code_challenge_method=S256";
|
+ "&code_challenge_method=S256";
|
||||||
|
|
||||||
res.status(302).header("Location", authUrl);
|
res.redirect(authUrl);
|
||||||
return null;
|
return null;
|
||||||
}).with();
|
}).with();
|
||||||
|
|
||||||
@@ -165,9 +165,8 @@ public class OidcExtension implements FlashExtension {
|
|||||||
);
|
);
|
||||||
config.sessionStore().save(session);
|
config.sessionStore().save(session);
|
||||||
|
|
||||||
res.status(302)
|
res.header("Set-Cookie", sessionCookie(session.id()))
|
||||||
.header("Set-Cookie", sessionCookie(session.id()))
|
.redirect(entry.originalUrl());
|
||||||
.header("Location", entry.originalUrl());
|
|
||||||
return null;
|
return null;
|
||||||
}).with();
|
}).with();
|
||||||
|
|
||||||
@@ -200,9 +199,8 @@ public class OidcExtension implements FlashExtension {
|
|||||||
location = config.postLogoutRedirectUri();
|
location = config.postLogoutRedirectUri();
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(302)
|
res.header("Set-Cookie", clearCookie)
|
||||||
.header("Set-Cookie", clearCookie)
|
.redirect(location);
|
||||||
.header("Location", location);
|
|
||||||
return null;
|
return null;
|
||||||
}).with();
|
}).with();
|
||||||
|
|
||||||
@@ -212,7 +210,9 @@ public class OidcExtension implements FlashExtension {
|
|||||||
if (roles != null) return List.of(oidcMw.rolesMiddleware(roles.value()));
|
if (roles != null) return List.of(oidcMw.rolesMiddleware(roles.value()));
|
||||||
|
|
||||||
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
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();
|
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
|
* Like {@link #protect()} but also enforces that the caller holds at least one
|
||||||
* of the given roles (OR semantics). Roles are extracted via
|
* of the given roles (OR semantics). Roles are extracted via
|
||||||
@@ -90,10 +113,40 @@ public class OidcMiddleware {
|
|||||||
// -- Package-private: AnnotationProcessor hooks ---------------------------
|
// -- Package-private: AnnotationProcessor hooks ---------------------------
|
||||||
|
|
||||||
Middleware authenticatedMiddleware() { return protect(); }
|
Middleware authenticatedMiddleware() { return protect(); }
|
||||||
|
Middleware optionalMiddleware() { return optional(); }
|
||||||
Middleware rolesMiddleware(String[] required) { return requireRole(required); }
|
Middleware rolesMiddleware(String[] required) { return requireRole(required); }
|
||||||
|
|
||||||
// -- Internals ------------------------------------------------------------
|
// -- 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
|
* Returns claims on success, or {@code null} if a redirect was already written to
|
||||||
* {@code res}. Throws {@link HttpException} 401/403 for API clients.
|
* {@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
|
// Browser — redirect to login, preserving the original URL in state
|
||||||
String loginUrl = config.routePrefix() + "/login?redirect="
|
String loginUrl = config.routePrefix() + "/login?redirect="
|
||||||
+ URLEncoder.encode(req.path(), StandardCharsets.UTF_8);
|
+ URLEncoder.encode(req.path(), StandardCharsets.UTF_8);
|
||||||
res.status(302).header("Location", loginUrl);
|
res.redirect(loginUrl);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ If `flash-ext-oidc` is installed **after** this extension, OIDC security schemes
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```java
|
```java
|
||||||
FlashApp.of(new HttpServer(config))
|
FlashApp.create(8080)
|
||||||
.install(new JacksonExtension())
|
.install(new JacksonExtension())
|
||||||
.install(new OpenApiExtension("/openapi", "My API", "2.0.0", "Optional description"))
|
.install(new OpenApiExtension("/openapi", "My API", "2.0.0", "Optional description"))
|
||||||
.register(new MyHandler());
|
.register(new MyHandler());
|
||||||
|
|||||||
+2
-2
@@ -3,8 +3,8 @@ package dev.relism.ext.openapi;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
|
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
|
||||||
import dev.relism.extension.ExtensionContext;
|
import dev.relism.extension.ExtensionContext;
|
||||||
import dev.relism.extension.FlashApp;
|
|
||||||
import dev.relism.extension.FlashExtension;
|
import dev.relism.extension.FlashExtension;
|
||||||
|
import dev.relism.extension.FlashRegistrar;
|
||||||
import dev.relism.http.ContentType;
|
import dev.relism.http.ContentType;
|
||||||
import dev.relism.routing.Route;
|
import dev.relism.routing.Route;
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ public class OpenApiExtension implements FlashExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void install(FlashApp app, ExtensionContext ctx) {
|
public void install(FlashRegistrar app, ExtensionContext ctx) {
|
||||||
ObjectMapper jsonMapper = ctx.require(ObjectMapper.class);
|
ObjectMapper jsonMapper = ctx.require(ObjectMapper.class);
|
||||||
YAMLMapper yamlMapper = new YAMLMapper();
|
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-jackson</module>
|
||||||
<module>flash-ext-openapi</module>
|
<module>flash-ext-openapi</module>
|
||||||
<module>flash-ext-oidc</module>
|
<module>flash-ext-oidc</module>
|
||||||
|
<module>flash-ext-routeviewer</module>
|
||||||
</modules>
|
</modules>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import dev.relism.fpr.core.ByteView;
|
|||||||
import dev.relism.http.ContentType;
|
import dev.relism.http.ContentType;
|
||||||
import dev.relism.http.HttpStatus;
|
import dev.relism.http.HttpStatus;
|
||||||
import dev.relism.models.*;
|
import dev.relism.models.*;
|
||||||
import dev.relism.routing.AbstractRouter;
|
import dev.relism.extension.FlashConfiguration;
|
||||||
import dev.relism.routing.GlobalRouter;
|
import dev.relism.routing.GlobalRouter;
|
||||||
import dev.relism.routing.Middleware;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
@@ -22,13 +21,17 @@ import java.util.concurrent.Executors;
|
|||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flash HTTP server. Owns a {@link GlobalRouter} with two routing tiers:
|
* Pure I/O transport layer. Owns the {@link ServerSocket}, the virtual-thread executor,
|
||||||
* mounted sub-routers (matched by longest namespace prefix) and an internal
|
* and the keep-alive accept loop. All routing is delegated to the {@link GlobalRouter}
|
||||||
* router as fallback. Error handlers are scoped to their respective router.
|
* supplied at construction time.
|
||||||
|
*
|
||||||
|
* <p>This class is package-private — use {@link dev.relism.extension.FlashApp} as the
|
||||||
|
* single entry point for creating and configuring a Flash server.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class HttpServer {
|
class HttpServer implements ServerHandle {
|
||||||
private final HttpServerConfiguration configuration;
|
|
||||||
|
private final FlashConfiguration configuration;
|
||||||
private final ServerSocket serverSocket;
|
private final ServerSocket serverSocket;
|
||||||
private final GlobalRouter globalRouter;
|
private final GlobalRouter globalRouter;
|
||||||
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
|
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
|
||||||
@@ -44,7 +47,6 @@ public class HttpServer {
|
|||||||
private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8);
|
private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8);
|
||||||
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8);
|
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8);
|
||||||
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
|
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8);
|
private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
private static final byte[][] DIGITS = new byte[10][1];
|
private static final byte[][] DIGITS = new byte[10][1];
|
||||||
@@ -54,16 +56,20 @@ public class HttpServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the server with optional server-level middlewares applied to every registered route.
|
* Creates the transport with a pre-built router. Called exclusively by
|
||||||
* Middlewares are pre-fused at construction time; the first element executes outermost.
|
* {@link dev.relism.extension.FlashApp}.
|
||||||
|
*
|
||||||
|
* @param configuration server configuration (port, host, buffer sizes)
|
||||||
|
* @param globalRouter the fully-wired router to dispatch requests to
|
||||||
*/
|
*/
|
||||||
public HttpServer(HttpServerConfiguration configuration, Middleware... middlewares) throws IOException {
|
HttpServer(FlashConfiguration configuration, GlobalRouter globalRouter) throws IOException {
|
||||||
this.configuration = configuration;
|
this.configuration = configuration;
|
||||||
this.serverSocket = new ServerSocket(configuration.getPort());
|
this.serverSocket = new ServerSocket(configuration.getPort());
|
||||||
this.globalRouter = new GlobalRouter(middlewares);
|
this.globalRouter = globalRouter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns a future that completes once the accept loop is running and the server is ready. */
|
/** Returns a future that completes once the accept loop is running and the server is ready. */
|
||||||
|
@Override
|
||||||
public CompletableFuture<Void> start() {
|
public CompletableFuture<Void> start() {
|
||||||
Thread.ofPlatform().name("flash-accept-loop").daemon(false).start(this::run);
|
Thread.ofPlatform().name("flash-accept-loop").daemon(false).start(this::run);
|
||||||
return readyFuture;
|
return readyFuture;
|
||||||
@@ -85,6 +91,7 @@ public class HttpServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Closes all active connections and shuts down the executor. Returns when complete. */
|
/** Closes all active connections and shuts down the executor. Returns when complete. */
|
||||||
|
@Override
|
||||||
public CompletableFuture<Void> stop() {
|
public CompletableFuture<Void> stop() {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
try {
|
try {
|
||||||
@@ -104,59 +111,7 @@ public class HttpServer {
|
|||||||
return CompletableFuture.completedFuture(null);
|
return CompletableFuture.completedFuture(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Hot-path ──────────────────────────────────────────────────────────────
|
||||||
* Mounts a sub-router under {@code namespace}. Requests whose path starts with the
|
|
||||||
* namespace are dispatched to {@code router}; longest prefix wins.
|
|
||||||
*
|
|
||||||
* @throws dev.relism.exceptions.DuplicateNamespaceException if {@code namespace} is already mounted
|
|
||||||
*/
|
|
||||||
public HttpServer mount(String namespace, AbstractRouter router) {
|
|
||||||
globalRouter.mount(namespace, router);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public HttpServer onNotFound(SimpleHandler.FunctionalHandler handler) {
|
|
||||||
globalRouter.onNotFound(handler);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public HttpServer onException(AbstractRouter.ExceptionHandler handler) {
|
|
||||||
globalRouter.onException(handler);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Direct registration (called by FlashApp via RouteHandle.with()) ──────
|
|
||||||
|
|
||||||
public HttpServer doRegister(dev.relism.http.HttpMethod method, String path,
|
|
||||||
SimpleHandler.FunctionalHandler handler,
|
|
||||||
dev.relism.routing.Middleware[] middlewares) {
|
|
||||||
globalRouter.doRegister(method, path, handler, middlewares);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public HttpServer doRegister(RequestHandler handler,
|
|
||||||
dev.relism.routing.Middleware[] middlewares) {
|
|
||||||
globalRouter.doRegister(handler, middlewares);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fluent registration (delegates to globalRouter) ───────────────────────
|
|
||||||
|
|
||||||
public dev.relism.routing.RouteHandle<HttpServer> register(RequestHandler handler) {
|
|
||||||
return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(handler, m));
|
|
||||||
}
|
|
||||||
|
|
||||||
public dev.relism.routing.RouteHandle<HttpServer> get (String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.GET, path, h, m)); }
|
|
||||||
public dev.relism.routing.RouteHandle<HttpServer> post (String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.POST, path, h, m)); }
|
|
||||||
public dev.relism.routing.RouteHandle<HttpServer> put (String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.PUT, path, h, m)); }
|
|
||||||
public dev.relism.routing.RouteHandle<HttpServer> delete (String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.DELETE, path, h, m)); }
|
|
||||||
public dev.relism.routing.RouteHandle<HttpServer> patch (String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.PATCH, path, h, m)); }
|
|
||||||
public dev.relism.routing.RouteHandle<HttpServer> options(String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.OPTIONS, path, h, m)); }
|
|
||||||
public dev.relism.routing.RouteHandle<HttpServer> head (String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.HEAD, path, h, m)); }
|
|
||||||
public dev.relism.routing.RouteHandle<HttpServer> trace (String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.TRACE, path, h, m)); }
|
|
||||||
public dev.relism.routing.RouteHandle<HttpServer> connect(String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.CONNECT, path, h, m)); }
|
|
||||||
public dev.relism.routing.RouteHandle<HttpServer> purge (String path, SimpleHandler.FunctionalHandler h) { return new dev.relism.routing.RouteHandle<>(this, m -> globalRouter.doRegister(dev.relism.http.HttpMethod.PURGE, path, h, m)); }
|
|
||||||
|
|
||||||
|
|
||||||
private void process(Socket socket) {
|
private void process(Socket socket) {
|
||||||
activeSockets.add(socket);
|
activeSockets.add(socket);
|
||||||
@@ -210,7 +165,6 @@ public class HttpServer {
|
|||||||
|
|
||||||
private static boolean isKeepAlive(Request request) {
|
private static boolean isKeepAlive(Request request) {
|
||||||
if (request.headerEquals("Connection", "close")) return false;
|
if (request.headerEquals("Connection", "close")) return false;
|
||||||
// Detect HTTP version from last byte of protocol field: "HTTP/1.1" → '1', "HTTP/1.0" → '0'
|
|
||||||
ByteView protocol = request.getRequestLine().getProtocol();
|
ByteView protocol = request.getRequestLine().getProtocol();
|
||||||
return protocol.length() == 8 && protocol.byteAt(7) == '1'
|
return protocol.length() == 8 && protocol.byteAt(7) == '1'
|
||||||
|| request.headerEquals("Connection", "keep-alive");
|
|| request.headerEquals("Connection", "keep-alive");
|
||||||
@@ -246,7 +200,6 @@ public class HttpServer {
|
|||||||
out.flush();
|
out.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Streaming write path — extracted from {@link #writeResponse} to keep the hot method small. */
|
|
||||||
private static void writeStreamingBody(OutputStream out, Response response, boolean keepAlive) throws IOException {
|
private static void writeStreamingBody(OutputStream out, Response response, boolean keepAlive) throws IOException {
|
||||||
if (!response.isChunked()) {
|
if (!response.isChunked()) {
|
||||||
out.write(CONTENT_LENGTH);
|
out.write(CONTENT_LENGTH);
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package dev.relism;
|
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
@Builder
|
|
||||||
public class HttpServerConfiguration {
|
|
||||||
private int port;
|
|
||||||
private String host;
|
|
||||||
@Builder.Default
|
|
||||||
private int acceptorThreads = 1;
|
|
||||||
@Builder.Default
|
|
||||||
private int maxHeaderBufferSize = 64 * 1024;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package dev.relism;
|
||||||
|
|
||||||
|
import dev.relism.extension.FlashConfiguration;
|
||||||
|
import dev.relism.routing.GlobalRouter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public handle to the underlying HTTP transport. Returned by {@link #create} so that
|
||||||
|
* {@link dev.relism.extension.FlashApp} can start and stop the server without holding
|
||||||
|
* a direct reference to the package-private {@link HttpServer}.
|
||||||
|
*/
|
||||||
|
public interface ServerHandle {
|
||||||
|
|
||||||
|
CompletableFuture<Void> start();
|
||||||
|
CompletableFuture<Void> stop();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the HTTP transport. Called exclusively by
|
||||||
|
* {@link dev.relism.extension.FlashApp}.
|
||||||
|
*/
|
||||||
|
static ServerHandle create(FlashConfiguration config, GlobalRouter router) throws IOException {
|
||||||
|
return new HttpServer(config, router);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package dev.relism.extension;
|
package dev.relism.extension;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared registry passed to every extension during {@link FlashExtension#install}.
|
* Shared registry passed to every extension during {@link FlashExtension#install}.
|
||||||
@@ -9,14 +10,36 @@ import java.util.*;
|
|||||||
* <li><b>Service sharing</b> — provide/require typed objects (e.g. {@code ObjectMapper},
|
* <li><b>Service sharing</b> — provide/require typed objects (e.g. {@code ObjectMapper},
|
||||||
* {@code OpenApiBuilder}) so extensions can build on each other.</li>
|
* {@code OpenApiBuilder}) so extensions can build on each other.</li>
|
||||||
* <li><b>Annotation processing</b> — register {@link AnnotationProcessor}s that
|
* <li><b>Annotation processing</b> — register {@link AnnotationProcessor}s that
|
||||||
* {@link FlashApp} calls for every handler, injecting middleware derived from
|
* are invoked for every handler, injecting middleware derived from
|
||||||
* annotations ({@code @RolesAllowed}, {@code @Authenticated}, etc.).</li>
|
* annotations ({@code @RolesAllowed}, {@code @Authenticated}, etc.).</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>A child context (created via {@link #child()}) inherits all services and processors
|
||||||
|
* from its parent. Services provided and processors added on the child are scoped to it
|
||||||
|
* and not visible in the parent or sibling scopes.
|
||||||
*/
|
*/
|
||||||
public class ExtensionContext {
|
public class ExtensionContext {
|
||||||
|
|
||||||
|
private final ExtensionContext parent;
|
||||||
private final Map<Class<?>, Object> registry = new LinkedHashMap<>();
|
private final Map<Class<?>, Object> registry = new LinkedHashMap<>();
|
||||||
private final List<AnnotationProcessor> processors = new ArrayList<>();
|
private final List<AnnotationProcessor> processors = new ArrayList<>();
|
||||||
|
private final List<RouteListener> routeListeners = new ArrayList<>();
|
||||||
|
|
||||||
|
public ExtensionContext() {
|
||||||
|
this.parent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExtensionContext(ExtensionContext parent) {
|
||||||
|
this.parent = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a child context that inherits this context's services and processors.
|
||||||
|
* Services provided and processors added on the child do not affect the parent.
|
||||||
|
*/
|
||||||
|
public ExtensionContext child() {
|
||||||
|
return new ExtensionContext(this);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Service registry ─────────────────────────────────────────────────────
|
// ── Service registry ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -27,11 +50,13 @@ public class ExtensionContext {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the service registered under {@code type}.
|
* Retrieves the service registered under {@code type}.
|
||||||
* Throws {@link IllegalStateException} if not present — install order matters.
|
* Checks own scope first, then the parent chain.
|
||||||
|
* Throws {@link IllegalStateException} if not found — install order matters.
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public <T> T require(Class<T> type) {
|
public <T> T require(Class<T> type) {
|
||||||
T val = (T) registry.get(type);
|
T val = (T) registry.get(type);
|
||||||
|
if (val == null && parent != null) val = parent.find(type).orElse(null);
|
||||||
if (val == null)
|
if (val == null)
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"Extension dependency not found: " + type.getSimpleName() +
|
"Extension dependency not found: " + type.getSimpleName() +
|
||||||
@@ -39,24 +64,58 @@ public class ExtensionContext {
|
|||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the service under {@code type}, or empty if not installed. */
|
/** Returns the service under {@code type}, or empty if not installed in this scope or any parent. */
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public <T> Optional<T> find(Class<T> type) {
|
public <T> Optional<T> find(Class<T> type) {
|
||||||
return Optional.ofNullable((T) registry.get(type));
|
T val = (T) registry.get(type);
|
||||||
|
if (val != null) return Optional.of(val);
|
||||||
|
return parent != null ? parent.find(type) : Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Annotation processors ────────────────────────────────────────────────
|
// ── Annotation processors ────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers an {@link AnnotationProcessor}. Called by extensions during
|
* Registers an {@link AnnotationProcessor}. Called by extensions during
|
||||||
* {@link FlashExtension#install}. Processors are invoked in registration order.
|
* {@link FlashExtension#install}. Processors are invoked in registration order
|
||||||
|
* (parent processors first, then own).
|
||||||
*/
|
*/
|
||||||
public void addAnnotationProcessor(AnnotationProcessor processor) {
|
public void addAnnotationProcessor(AnnotationProcessor processor) {
|
||||||
processors.add(processor);
|
processors.add(processor);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns an unmodifiable view of all registered processors. */
|
/**
|
||||||
|
* Returns all processors visible from this context: parent processors first,
|
||||||
|
* then processors added directly to this context.
|
||||||
|
*/
|
||||||
List<AnnotationProcessor> processors() {
|
List<AnnotationProcessor> processors() {
|
||||||
return Collections.unmodifiableList(processors);
|
if (parent == null) return Collections.unmodifiableList(processors);
|
||||||
|
List<AnnotationProcessor> parentProcessors = parent.processors();
|
||||||
|
if (processors.isEmpty()) return parentProcessors;
|
||||||
|
return Stream.concat(parentProcessors.stream(), processors.stream()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Route listeners ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a {@link RouteListener} that will be notified once for every route
|
||||||
|
* registered on this context's {@link dev.relism.extension.FlashApp} or any
|
||||||
|
* {@link dev.relism.extension.FlashScope} that inherits from it.
|
||||||
|
*
|
||||||
|
* <p>Call this inside {@link FlashExtension#install} to observe all routes.
|
||||||
|
* If no listener is registered the emission path is a no-op.
|
||||||
|
*/
|
||||||
|
public void addRouteListener(RouteListener listener) {
|
||||||
|
routeListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all route listeners visible from this context: parent listeners first,
|
||||||
|
* then listeners added directly to this context.
|
||||||
|
*/
|
||||||
|
List<RouteListener> routeListeners() {
|
||||||
|
if (parent == null) return Collections.unmodifiableList(routeListeners);
|
||||||
|
List<RouteListener> parentListeners = parent.routeListeners();
|
||||||
|
if (routeListeners.isEmpty()) return parentListeners;
|
||||||
|
return Stream.concat(parentListeners.stream(), routeListeners.stream()).toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,107 @@
|
|||||||
package dev.relism.extension;
|
package dev.relism.extension;
|
||||||
|
|
||||||
import dev.relism.HttpServer;
|
import dev.relism.ServerHandle;
|
||||||
import dev.relism.http.HttpMethod;
|
import dev.relism.http.HttpMethod;
|
||||||
import dev.relism.models.RequestHandler;
|
import dev.relism.models.RequestHandler;
|
||||||
import dev.relism.models.SimpleHandler;
|
import dev.relism.models.SimpleHandler;
|
||||||
import dev.relism.routing.AbstractRouter;
|
import dev.relism.routing.AbstractRouter;
|
||||||
|
import dev.relism.routing.GlobalRouter;
|
||||||
import dev.relism.routing.Middleware;
|
import dev.relism.routing.Middleware;
|
||||||
|
import dev.relism.routing.Route;
|
||||||
import dev.relism.routing.RouteHandle;
|
import dev.relism.routing.RouteHandle;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.function.Consumer;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Primary entry point for working with Flash. Wraps {@link HttpServer} and
|
* Primary entry point for Flash. Creates and owns both the {@link GlobalRouter} and
|
||||||
* wires the extension layer.
|
* the {@link HttpServer} (pure I/O transport). All route registration goes through
|
||||||
*
|
* {@code FlashApp} or a {@link FlashScope} — never through the server or router directly.
|
||||||
* <p>The key addition over raw {@link HttpServer} is the annotation-aware
|
|
||||||
* {@link #register} override: before delegating to Flash it runs all registered
|
|
||||||
* {@link AnnotationProcessor}s and prepends any injected middlewares
|
|
||||||
* (e.g. from {@code @RolesAllowed}) to the explicit ones.
|
|
||||||
*
|
|
||||||
* <p>Route registration is lazy: calling {@code get()}, {@code post()}, or
|
|
||||||
* {@code register()} returns a {@link RouteHandle} that is not yet registered.
|
|
||||||
* The route is registered automatically before the next operation or at
|
|
||||||
* {@link #start()}. Call {@link RouteHandle#with} explicitly to add middleware:
|
|
||||||
*
|
*
|
||||||
|
* <p>Create via the static factories:
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* FlashApp.of(new HttpServer(config))
|
* FlashApp app = FlashApp.create(8080);
|
||||||
* .install(new JacksonExtension())
|
* FlashApp app = FlashApp.create(FlashConfiguration.builder().port(8080).build());
|
||||||
* .install(new OidcExtension(OidcConfig.fromEnv()))
|
|
||||||
* .install(new OpenApiExtension("/openapi"))
|
|
||||||
* .start();
|
|
||||||
*
|
|
||||||
* // No middleware — .with() is optional
|
|
||||||
* app.get("/ping", (req, res) -> "pong");
|
|
||||||
* app.get("/hello", (req, res) -> "Hello");
|
|
||||||
*
|
|
||||||
* // With middleware
|
|
||||||
* app.get("/me", (req, res) -> ClaimsHolder.user().email()).with(oidc.protect());
|
|
||||||
* app.get("/admin", (req, res) -> "secret").with(oidc.requireRole("admin"));
|
|
||||||
*
|
|
||||||
* // Class-based — @Authenticated / @RolesAllowed auto-injected, .with() optional
|
|
||||||
* app.register(new HomePage());
|
|
||||||
* app.register(new MePage()); // @Authenticated handled by annotation processor
|
|
||||||
* app.register(new AdminPage()); // @RolesAllowed("admin") handled automatically
|
|
||||||
*
|
|
||||||
* app.start();
|
|
||||||
* }</pre>
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>Install extensions, register routes, mount namespaces, then start:
|
||||||
|
* <pre>{@code
|
||||||
|
* FlashApp.create(8080)
|
||||||
|
* .install(new JacksonExtension())
|
||||||
|
* .install(new OidcExtension(config))
|
||||||
|
* .get("/ping", (req, res) -> "pong")
|
||||||
|
* .get("/me", (req, res) -> ClaimsHolder.user().email()).with(oidc.protect())
|
||||||
|
* .register(new HomePage()) // @Route + annotation processors applied
|
||||||
|
* .scan("dev.example.handlers") // classpath scan, no-arg constructors
|
||||||
|
* .mount("/api", scope -> {
|
||||||
|
* scope.register(new UserHandler()); // @Authenticated works here
|
||||||
|
* scope.get("/health", (req, res) -> "ok");
|
||||||
|
* })
|
||||||
|
* .start();
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <h3>Auto-flush</h3>
|
||||||
|
* Calling any registration method returns a {@link RouteHandle}. Calling
|
||||||
|
* {@link RouteHandle#with} is optional — if omitted, the route is registered
|
||||||
|
* automatically before the next operation or at {@link #start()}. This means
|
||||||
|
* trailing {@code .with()} calls are never required for routes with no middleware.
|
||||||
*/
|
*/
|
||||||
public class FlashApp {
|
public final class FlashApp implements FlashRegistrar {
|
||||||
|
|
||||||
private final HttpServer server;
|
private final GlobalRouter router;
|
||||||
|
private final ServerHandle server;
|
||||||
private final ExtensionContext ctx = new ExtensionContext();
|
private final ExtensionContext ctx = new ExtensionContext();
|
||||||
|
|
||||||
/** The last returned RouteHandle that has not yet been registered. */
|
/** The last returned RouteHandle not yet registered — auto-flushed before the next operation. */
|
||||||
private RouteHandle<?> pending;
|
private RouteHandle<?> pending;
|
||||||
|
|
||||||
private FlashApp(HttpServer server) {
|
/**
|
||||||
this.server = server;
|
* Global middlewares applied to every route, regardless of how it is registered
|
||||||
|
* (lambda, class-based, or via {@link #scan}).
|
||||||
|
* Accumulated via {@link #use}; applied outermost in the chain (before injected and
|
||||||
|
* explicit middlewares).
|
||||||
|
*/
|
||||||
|
private final List<Middleware> globalMiddlewares = new ArrayList<>();
|
||||||
|
|
||||||
|
private FlashApp(FlashConfiguration config) {
|
||||||
|
this.router = new GlobalRouter();
|
||||||
|
try {
|
||||||
|
this.server = ServerHandle.create(config, router);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Failed to bind server socket on port " + config.getPort(), e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static FlashApp of(HttpServer server) {
|
// ── Factories ─────────────────────────────────────────────────────────────
|
||||||
return new FlashApp(server);
|
|
||||||
|
/**
|
||||||
|
* Creates a {@code FlashApp} listening on {@code port} with default configuration.
|
||||||
|
*
|
||||||
|
* @param port the TCP port to bind
|
||||||
|
*/
|
||||||
|
public static FlashApp create(int port) {
|
||||||
|
return create(FlashConfiguration.builder().port(port).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a {@code FlashApp} with full server configuration.
|
||||||
|
*
|
||||||
|
* @param config server configuration (port, host, buffer sizes, etc.)
|
||||||
|
*/
|
||||||
|
public static FlashApp create(FlashConfiguration config) {
|
||||||
|
return new FlashApp(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pending flush ─────────────────────────────────────────────────────────
|
// ── Pending flush ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers any pending route (returned by the previous {@code get/post/register}
|
* Registers any pending route (from the previous {@code get/post/register} call)
|
||||||
* call) with no middleware, if it has not already been registered via
|
* with no middleware if it has not already been committed via {@link RouteHandle#with}.
|
||||||
* {@link RouteHandle#with}.
|
|
||||||
*/
|
*/
|
||||||
private void flushPending() {
|
private void flushPending() {
|
||||||
if (pending != null) {
|
if (pending != null) {
|
||||||
@@ -80,103 +110,239 @@ public class FlashApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private <P> RouteHandle<P> pending(RouteHandle<P> handle) {
|
private <P> RouteHandle<P> track(RouteHandle<P> handle) {
|
||||||
flushPending();
|
flushPending();
|
||||||
pending = handle;
|
pending = handle;
|
||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Extension installation ────────────────────────────────────────────────
|
// ── FlashRegistrar — extension installation ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs an extension. Extensions receive this {@code FlashApp} as a
|
||||||
|
* {@link FlashRegistrar} so they can register routes and expose services.
|
||||||
|
*
|
||||||
|
* @param ext the extension to install
|
||||||
|
* @return {@code this} for chaining
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
public FlashApp install(FlashExtension ext) {
|
public FlashApp install(FlashExtension ext) {
|
||||||
flushPending();
|
flushPending();
|
||||||
ext.install(this, ctx);
|
ext.install(this, ctx);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Exposes the context so callers can retrieve services installed by extensions. */
|
// ── Global middleware ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers one or more global middlewares applied to <em>every</em> route on this app,
|
||||||
|
* regardless of how the route is registered (lambda, class-based, or via {@link #scan}).
|
||||||
|
*
|
||||||
|
* <p>Global middlewares execute outermost — before annotation-injected middlewares
|
||||||
|
* (e.g. {@code @Authenticated}) and before any explicit {@link RouteHandle#with} chain.
|
||||||
|
* Execution order mirrors the declaration order: the first argument wraps everything else.
|
||||||
|
*
|
||||||
|
* <p>Must be called before {@link #start()}. Calling {@code use} after routes have already
|
||||||
|
* been registered will not retroactively affect those routes.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* Middleware cors = next -> (req, res) -> {
|
||||||
|
* res.header("Access-Control-Allow-Origin", "*");
|
||||||
|
* if (req.method() == HttpMethod.OPTIONS) { res.status(204); return null; }
|
||||||
|
* return next.handle(req, res);
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* FlashApp.create(8080)
|
||||||
|
* .use(cors)
|
||||||
|
* .scan("dev.example.handlers")
|
||||||
|
* .start();
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* @param middlewares one or more middlewares to apply globally
|
||||||
|
* @return {@code this} for chaining
|
||||||
|
*/
|
||||||
|
public FlashApp use(Middleware... middlewares) {
|
||||||
|
flushPending();
|
||||||
|
globalMiddlewares.addAll(Arrays.asList(middlewares));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepends global middlewares to an explicit per-route array.
|
||||||
|
* Returns {@code explicit} unchanged when no global middlewares have been registered
|
||||||
|
* (zero-allocation fast path).
|
||||||
|
*/
|
||||||
|
private Middleware[] withGlobal(Middleware[] explicit) {
|
||||||
|
if (globalMiddlewares.isEmpty()) return explicit;
|
||||||
|
return Stream.concat(globalMiddlewares.stream(), Arrays.stream(explicit))
|
||||||
|
.toArray(Middleware[]::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FlashRegistrar — route registration ───────────────────────────────────
|
||||||
|
|
||||||
|
@Override public RouteHandle<FlashApp> get (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.GET, path, h); }
|
||||||
|
@Override public RouteHandle<FlashApp> post (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.POST, path, h); }
|
||||||
|
@Override public RouteHandle<FlashApp> put (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PUT, path, h); }
|
||||||
|
@Override public RouteHandle<FlashApp> delete (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.DELETE, path, h); }
|
||||||
|
@Override public RouteHandle<FlashApp> patch (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PATCH, path, h); }
|
||||||
|
@Override public RouteHandle<FlashApp> options(String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.OPTIONS, path, h); }
|
||||||
|
@Override public RouteHandle<FlashApp> head (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.HEAD, path, h); }
|
||||||
|
@Override public RouteHandle<FlashApp> trace (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.TRACE, path, h); }
|
||||||
|
@Override public RouteHandle<FlashApp> connect(String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.CONNECT, path, h); }
|
||||||
|
@Override public RouteHandle<FlashApp> purge (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PURGE, path, h); }
|
||||||
|
|
||||||
|
private RouteHandle<FlashApp> routeLambda(HttpMethod method, String path, SimpleHandler.FunctionalHandler h) {
|
||||||
|
return track(new RouteHandle<>(this, m -> {
|
||||||
|
Middleware[] all = withGlobal(m);
|
||||||
|
emit(method, path, null, List.of(), all);
|
||||||
|
router.doRegister(method, path, h, all);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begins registration of a class-based handler. The class must carry a
|
||||||
|
* {@link Route @Route} annotation. All registered {@link AnnotationProcessor}s
|
||||||
|
* are run (e.g. to inject {@code @Authenticated} / {@code @RolesAllowed} middleware).
|
||||||
|
* Injected middlewares are prepended outermost to any explicit ones passed via
|
||||||
|
* {@link RouteHandle#with}.
|
||||||
|
*
|
||||||
|
* <p>Calling {@link RouteHandle#with} is optional — the route is registered
|
||||||
|
* automatically before the next operation or at {@link #start()}.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public RouteHandle<FlashApp> register(RequestHandler handler) {
|
||||||
|
return track(new RouteHandle<>(this, explicit -> {
|
||||||
|
List<Middleware> injected = ctx.processors().stream()
|
||||||
|
.flatMap(p -> p.process(handler.getClass()).stream())
|
||||||
|
.toList();
|
||||||
|
Middleware[] all = Stream.concat(
|
||||||
|
globalMiddlewares.stream(),
|
||||||
|
Stream.concat(injected.stream(), Arrays.stream(explicit))
|
||||||
|
).toArray(Middleware[]::new);
|
||||||
|
Route ann = handler.getClass().getAnnotation(Route.class);
|
||||||
|
if (ann != null) emit(ann.method(), ann.path(), handler.getClass(), injected, withGlobal(explicit));
|
||||||
|
router.doRegister(handler, all);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans {@code packageName} for classes that extend {@link RequestHandler} and
|
||||||
|
* carry {@link Route @Route}. Each is instantiated via its no-arg constructor,
|
||||||
|
* run through annotation processors, and registered.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* FlashApp.create(8080)
|
||||||
|
* .install(new OidcExtension(config))
|
||||||
|
* .scan("dev.example.handlers"); // @Authenticated / @RolesAllowed auto-applied
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public FlashApp scan(String packageName) {
|
||||||
|
flushPending();
|
||||||
|
PackageScanner.findHandlers(packageName).forEach(cls -> register(instantiate(cls)).ensureRegistered());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Namespace mounting ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts a scoped sub-router under {@code namespace}. The {@code configure} consumer
|
||||||
|
* receives a {@link FlashScope} that has its own child {@link ExtensionContext}
|
||||||
|
* inheriting all parent services and annotation processors.
|
||||||
|
*
|
||||||
|
* <p>Routes registered on the scope automatically get the namespace prefix prepended.
|
||||||
|
* Annotation processors (e.g. from OIDC) apply identically inside the scope.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* app.mount("/api", scope -> {
|
||||||
|
* scope.register(new UserHandler()); // @Authenticated works
|
||||||
|
* scope.get("/health", (req, res) -> "ok");
|
||||||
|
* scope.scan("dev.example.api");
|
||||||
|
* });
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* @param namespace the path prefix (e.g. {@code "/api"})
|
||||||
|
* @param configure consumer that registers routes on the scope
|
||||||
|
*/
|
||||||
|
public FlashApp mount(String namespace, Consumer<FlashScope> configure) {
|
||||||
|
flushPending();
|
||||||
|
FlashScope scope = new FlashScope(new dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl(),
|
||||||
|
namespace, ctx);
|
||||||
|
configure.accept(scope);
|
||||||
|
scope.flush();
|
||||||
|
router.mount(namespace, scope.router());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FlashRegistrar — error handlers ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FlashApp onException(AbstractRouter.ExceptionHandler handler) {
|
||||||
|
flushPending();
|
||||||
|
router.onException(handler);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FlashApp onNotFound(SimpleHandler.FunctionalHandler handler) {
|
||||||
|
flushPending();
|
||||||
|
router.onNotFound(handler);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FlashRegistrar — context ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Override
|
||||||
public ExtensionContext ctx() {
|
public ExtensionContext ctx() {
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Handler registration (annotation-aware) ───────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Begins registration of a class-based handler. Before registering, all
|
|
||||||
* {@link AnnotationProcessor}s are called (e.g. to inject OIDC middleware from
|
|
||||||
* {@code @Authenticated} / {@code @RolesAllowed}). Their middlewares are prepended
|
|
||||||
* outermost to any extra middlewares supplied via {@link RouteHandle#with}.
|
|
||||||
*
|
|
||||||
* <p>Calling {@link RouteHandle#with} is optional when no extra middleware is needed —
|
|
||||||
* the route is registered automatically before the next operation or at {@link #start()}.
|
|
||||||
*
|
|
||||||
* <p>Middleware execution order: injected (from annotations) → explicit (.with()) → handler.
|
|
||||||
*/
|
|
||||||
public RouteHandle<FlashApp> register(RequestHandler handler) {
|
|
||||||
return pending(new RouteHandle<>(this, explicit -> {
|
|
||||||
List<Middleware> injected = ctx.processors().stream()
|
|
||||||
.flatMap(p -> p.process(handler.getClass()).stream())
|
|
||||||
.toList();
|
|
||||||
Middleware[] all = injected.isEmpty()
|
|
||||||
? explicit
|
|
||||||
: Stream.concat(injected.stream(), Arrays.stream(explicit))
|
|
||||||
.toArray(Middleware[]::new);
|
|
||||||
server.doRegister(handler, all);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Lambda route registration ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Begins registration of a lambda route. Calling {@link RouteHandle#with} is
|
|
||||||
* optional when no middleware is needed — the route is registered automatically
|
|
||||||
* before the next operation or at {@link #start()}.
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* app.get("/ping", (req, res) -> "pong"); // no middleware
|
|
||||||
* app.get("/me", (req, res) -> ClaimsHolder.user().email())
|
|
||||||
* .with(oidc.protect()); // with middleware
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public RouteHandle<FlashApp> get (String path, SimpleHandler.FunctionalHandler h) { return pending(new RouteHandle<>(this, m -> server.doRegister(HttpMethod.GET, path, h, m))); }
|
|
||||||
public RouteHandle<FlashApp> post (String path, SimpleHandler.FunctionalHandler h) { return pending(new RouteHandle<>(this, m -> server.doRegister(HttpMethod.POST, path, h, m))); }
|
|
||||||
public RouteHandle<FlashApp> put (String path, SimpleHandler.FunctionalHandler h) { return pending(new RouteHandle<>(this, m -> server.doRegister(HttpMethod.PUT, path, h, m))); }
|
|
||||||
public RouteHandle<FlashApp> delete (String path, SimpleHandler.FunctionalHandler h) { return pending(new RouteHandle<>(this, m -> server.doRegister(HttpMethod.DELETE, path, h, m))); }
|
|
||||||
public RouteHandle<FlashApp> patch (String path, SimpleHandler.FunctionalHandler h) { return pending(new RouteHandle<>(this, m -> server.doRegister(HttpMethod.PATCH, path, h, m))); }
|
|
||||||
public RouteHandle<FlashApp> options(String path, SimpleHandler.FunctionalHandler h) { return pending(new RouteHandle<>(this, m -> server.doRegister(HttpMethod.OPTIONS, path, h, m))); }
|
|
||||||
public RouteHandle<FlashApp> head (String path, SimpleHandler.FunctionalHandler h) { return pending(new RouteHandle<>(this, m -> server.doRegister(HttpMethod.HEAD, path, h, m))); }
|
|
||||||
|
|
||||||
public FlashApp mount(String namespace, AbstractRouter router) {
|
|
||||||
flushPending();
|
|
||||||
server.mount(namespace, router);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public FlashApp onException(AbstractRouter.ExceptionHandler handler) {
|
|
||||||
flushPending();
|
|
||||||
server.onException(handler);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public FlashApp onNotFound(SimpleHandler.FunctionalHandler handler) {
|
|
||||||
flushPending();
|
|
||||||
server.onNotFound(handler);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flushes any pending route registration and starts the HTTP server.
|
||||||
|
*
|
||||||
|
* @return a future that completes once the accept loop is running
|
||||||
|
*/
|
||||||
public CompletableFuture<Void> start() {
|
public CompletableFuture<Void> start() {
|
||||||
flushPending();
|
flushPending();
|
||||||
return server.start();
|
return server.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stops the HTTP server and closes all active connections. */
|
||||||
public CompletableFuture<Void> stop() {
|
public CompletableFuture<Void> stop() {
|
||||||
return server.stop();
|
return server.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Direct access to the underlying server for advanced use cases. */
|
// ── Internals ─────────────────────────────────────────────────────────────
|
||||||
public HttpServer server() {
|
|
||||||
return server;
|
@SuppressWarnings("unchecked")
|
||||||
|
private static RequestHandler instantiate(Class<?> cls) {
|
||||||
|
try {
|
||||||
|
return (RequestHandler) cls.getDeclaredConstructor().newInstance();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to instantiate handler: " + cls.getName() +
|
||||||
|
" — ensure it has a public no-arg constructor", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits a {@link RouteEvent} to all registered {@link RouteListener}s.
|
||||||
|
* No-op if no listener has been registered (fast empty-list check).
|
||||||
|
* Called once per route at boot time — never on the request hot-path.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private void emit(HttpMethod method, String path, Class<?> handlerClass,
|
||||||
|
List<Middleware> injected, Middleware[] explicit) {
|
||||||
|
List<RouteListener> listeners = ctx.routeListeners();
|
||||||
|
if (listeners.isEmpty()) return;
|
||||||
|
Middleware[] routerMws = router.routerMiddlewares();
|
||||||
|
List<Class<? extends Middleware>> chain = new ArrayList<>(routerMws.length + injected.size() + explicit.length);
|
||||||
|
for (Middleware m : routerMws) chain.add((Class<? extends Middleware>) m.getClass());
|
||||||
|
for (Middleware m : injected) chain.add((Class<? extends Middleware>) m.getClass());
|
||||||
|
for (Middleware m : explicit) chain.add((Class<? extends Middleware>) m.getClass());
|
||||||
|
RouteEvent event = new RouteEvent(method, path, router.getNamespace(),
|
||||||
|
router.getClass().getSimpleName(), handlerClass, List.copyOf(chain));
|
||||||
|
listeners.forEach(l -> l.onRoute(event));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package dev.relism.extension;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Value;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for a {@link FlashApp} instance.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* // Minimal — port only
|
||||||
|
* FlashApp.create(8080);
|
||||||
|
*
|
||||||
|
* // Full control
|
||||||
|
* FlashApp.create(FlashConfiguration.builder()
|
||||||
|
* .port(8080)
|
||||||
|
* .host("127.0.0.1")
|
||||||
|
* .maxHeaderBufferSize(128 * 1024)
|
||||||
|
* .build());
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
@Value
|
||||||
|
@Builder
|
||||||
|
public class FlashConfiguration {
|
||||||
|
|
||||||
|
int port;
|
||||||
|
String host;
|
||||||
|
|
||||||
|
/** Maximum size of the request header buffer in bytes. Default: 64 KB. */
|
||||||
|
@Builder.Default
|
||||||
|
int maxHeaderBufferSize = 64 * 1024;
|
||||||
|
}
|
||||||
@@ -1,25 +1,32 @@
|
|||||||
package dev.relism.extension;
|
package dev.relism.extension;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contract for all Flash extensions. An extension receives the full {@link FlashApp}
|
* Contract for all Flash extensions. An extension receives a {@link FlashRegistrar}
|
||||||
* so it can both register handlers on the server and expose services through
|
* (either a {@link FlashApp} or a {@link FlashScope}) so it can register routes and
|
||||||
* {@link ExtensionContext} for other extensions.
|
* expose shared services via {@link ExtensionContext}.
|
||||||
|
*
|
||||||
|
* <p>Extensions work identically whether installed at the top-level app or inside a
|
||||||
|
* mounted scope:
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* public class OpenApiExtension implements FlashExtension {
|
* public class RateLimitExtension implements FlashExtension {
|
||||||
* public void install(FlashApp app, ExtensionContext ctx) {
|
* public void install(FlashRegistrar app, ExtensionContext ctx) {
|
||||||
* ObjectMapper mapper = ctx.require(ObjectMapper.class);
|
* RateLimiter limiter = new RateLimiter(100);
|
||||||
* app.get("/openapi.json", (req, res) -> mapper.writeValueAsString(spec));
|
* ctx.provide(RateLimiter.class, limiter);
|
||||||
|
* app.onException((ex, req, res) -> { ... });
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* FlashApp.of(new HttpServer(config))
|
* // Top-level app
|
||||||
|
* FlashApp.create(8080)
|
||||||
* .install(new JacksonExtension())
|
* .install(new JacksonExtension())
|
||||||
* .install(new OpenApiExtension("/openapi.json"))
|
|
||||||
* .install(new OidcExtension(OidcConfig.fromEnv()));
|
* .install(new OidcExtension(OidcConfig.fromEnv()));
|
||||||
|
*
|
||||||
|
* // Scoped
|
||||||
|
* app.mount("/api", scope -> scope.install(new RateLimitExtension()));
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*/
|
*/
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface FlashExtension {
|
public interface FlashExtension {
|
||||||
void install(FlashApp app, ExtensionContext ctx);
|
void install(FlashRegistrar app, ExtensionContext ctx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package dev.relism.extension;
|
||||||
|
|
||||||
|
import dev.relism.models.RequestHandler;
|
||||||
|
import dev.relism.models.SimpleHandler;
|
||||||
|
import dev.relism.routing.AbstractRouter;
|
||||||
|
import dev.relism.routing.RouteHandle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common registration surface shared by {@link FlashApp} and {@link FlashScope}.
|
||||||
|
*
|
||||||
|
* <p>{@link FlashExtension#install} receives a {@code FlashRegistrar} so that extensions
|
||||||
|
* work identically whether installed at the top-level app or inside a mounted scope.
|
||||||
|
*
|
||||||
|
* <p>Route registration follows the auto-flush pattern: calling any registration method
|
||||||
|
* without a subsequent {@link RouteHandle#with} is equivalent to calling
|
||||||
|
* {@code .with()} with no arguments — the route is registered with no middleware.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* // In an extension:
|
||||||
|
* public void install(FlashRegistrar app, ExtensionContext ctx) {
|
||||||
|
* app.get("/health", (req, res) -> "ok");
|
||||||
|
* app.get("/secured", (req, res) -> user()).with(oidc.protect());
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public interface FlashRegistrar {
|
||||||
|
|
||||||
|
// ── Extension installation ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
FlashRegistrar install(FlashExtension ext);
|
||||||
|
|
||||||
|
// ── Route registration ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
RouteHandle<?> get (String path, SimpleHandler.FunctionalHandler h);
|
||||||
|
RouteHandle<?> post (String path, SimpleHandler.FunctionalHandler h);
|
||||||
|
RouteHandle<?> put (String path, SimpleHandler.FunctionalHandler h);
|
||||||
|
RouteHandle<?> delete (String path, SimpleHandler.FunctionalHandler h);
|
||||||
|
RouteHandle<?> patch (String path, SimpleHandler.FunctionalHandler h);
|
||||||
|
RouteHandle<?> options(String path, SimpleHandler.FunctionalHandler h);
|
||||||
|
RouteHandle<?> head (String path, SimpleHandler.FunctionalHandler h);
|
||||||
|
RouteHandle<?> trace (String path, SimpleHandler.FunctionalHandler h);
|
||||||
|
RouteHandle<?> connect(String path, SimpleHandler.FunctionalHandler h);
|
||||||
|
RouteHandle<?> purge (String path, SimpleHandler.FunctionalHandler h);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begins registration of a class-based handler. The class must carry a
|
||||||
|
* {@link dev.relism.routing.Route @Route} annotation. Annotation processors
|
||||||
|
* (e.g. {@code @Authenticated}, {@code @RolesAllowed}) are applied automatically.
|
||||||
|
*
|
||||||
|
* <p>Calling {@link RouteHandle#with} is optional — the route is registered
|
||||||
|
* automatically before the next operation or at {@code start()}.
|
||||||
|
*/
|
||||||
|
RouteHandle<?> register(RequestHandler h);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans {@code packageName} for classes that extend {@link RequestHandler} and
|
||||||
|
* carry {@link dev.relism.routing.Route @Route}. Each is instantiated via its
|
||||||
|
* no-arg constructor, run through annotation processors, and registered.
|
||||||
|
*/
|
||||||
|
FlashRegistrar scan(String packageName);
|
||||||
|
|
||||||
|
// ── Error handlers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
FlashRegistrar onException(AbstractRouter.ExceptionHandler h);
|
||||||
|
FlashRegistrar onNotFound(SimpleHandler.FunctionalHandler h);
|
||||||
|
|
||||||
|
// ── Context access ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns the {@link ExtensionContext} for this registrar (app or scope). */
|
||||||
|
ExtensionContext ctx();
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package dev.relism.extension;
|
||||||
|
|
||||||
|
import dev.relism.http.HttpMethod;
|
||||||
|
import dev.relism.models.RequestHandler;
|
||||||
|
import dev.relism.models.SimpleHandler;
|
||||||
|
import dev.relism.routing.AbstractRouter;
|
||||||
|
import dev.relism.routing.PathUtils;
|
||||||
|
import dev.relism.routing.Route;
|
||||||
|
import dev.relism.routing.RouteHandle;
|
||||||
|
import dev.relism.routing.Middleware;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Enumeration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.jar.JarEntry;
|
||||||
|
import java.util.jar.JarFile;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scoped registration context for a mounted sub-router namespace.
|
||||||
|
*
|
||||||
|
* <p>Obtained via {@link FlashApp#mount(String, java.util.function.Consumer)}.
|
||||||
|
* A scope has its own child {@link ExtensionContext} that inherits all services and
|
||||||
|
* annotation processors from the parent app, so extensions like {@code @Authenticated}
|
||||||
|
* and {@code @RolesAllowed} work identically inside a scope.
|
||||||
|
*
|
||||||
|
* <p>Extensions installed on a scope are scoped to that namespace and not visible
|
||||||
|
* in the parent or sibling scopes.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* app.mount("/api", scope -> {
|
||||||
|
* scope.install(new RateLimitExtension());
|
||||||
|
* scope.register(new UserHandler()); // @Authenticated auto-injected
|
||||||
|
* scope.get("/health", (req, res) -> "ok");
|
||||||
|
* scope.scan("dev.example.api.handlers");
|
||||||
|
* });
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public final class FlashScope implements FlashRegistrar {
|
||||||
|
|
||||||
|
private final AbstractRouter router;
|
||||||
|
private final String namespace;
|
||||||
|
private final ExtensionContext ctx;
|
||||||
|
|
||||||
|
/** Pending RouteHandle awaiting .with() — auto-flushed before each new registration. */
|
||||||
|
private RouteHandle<?> pending;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package-private — only {@link FlashApp} creates scopes.
|
||||||
|
*
|
||||||
|
* @param router the sub-router that will receive routes registered on this scope
|
||||||
|
* @param namespace the namespace prefix (e.g. {@code "/api"})
|
||||||
|
* @param parentCtx the parent app's ExtensionContext — a child is created from it
|
||||||
|
*/
|
||||||
|
FlashScope(AbstractRouter router, String namespace, ExtensionContext parentCtx) {
|
||||||
|
this.router = router;
|
||||||
|
this.namespace = namespace;
|
||||||
|
this.ctx = parentCtx.child();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pending flush ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void flushPending() {
|
||||||
|
if (pending != null) {
|
||||||
|
pending.ensureRegistered();
|
||||||
|
pending = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private <P> RouteHandle<P> track(RouteHandle<P> handle) {
|
||||||
|
flushPending();
|
||||||
|
pending = handle;
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FlashRegistrar — extension installation ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs an extension scoped to this namespace.
|
||||||
|
* The extension registers routes and services on this scope only.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public FlashScope install(FlashExtension ext) {
|
||||||
|
flushPending();
|
||||||
|
ext.install(this, ctx);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FlashRegistrar — route registration ───────────────────────────────────
|
||||||
|
|
||||||
|
@Override public RouteHandle<FlashScope> get (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.GET, path, h); }
|
||||||
|
@Override public RouteHandle<FlashScope> post (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.POST, path, h); }
|
||||||
|
@Override public RouteHandle<FlashScope> put (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PUT, path, h); }
|
||||||
|
@Override public RouteHandle<FlashScope> delete (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.DELETE, path, h); }
|
||||||
|
@Override public RouteHandle<FlashScope> patch (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PATCH, path, h); }
|
||||||
|
@Override public RouteHandle<FlashScope> options(String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.OPTIONS, path, h); }
|
||||||
|
@Override public RouteHandle<FlashScope> head (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.HEAD, path, h); }
|
||||||
|
@Override public RouteHandle<FlashScope> trace (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.TRACE, path, h); }
|
||||||
|
@Override public RouteHandle<FlashScope> connect(String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.CONNECT, path, h); }
|
||||||
|
@Override public RouteHandle<FlashScope> purge (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PURGE, path, h); }
|
||||||
|
|
||||||
|
private RouteHandle<FlashScope> routeLambda(HttpMethod method, String path, SimpleHandler.FunctionalHandler h) {
|
||||||
|
return track(new RouteHandle<>(this, m -> {
|
||||||
|
String full = ns(path);
|
||||||
|
emit(method, full, null, List.of(), m);
|
||||||
|
router.doRegister(method, full, h, m);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begins registration of a class-based handler. Annotation processors from the
|
||||||
|
* parent app and any installed on this scope are applied. The {@link Route @Route}
|
||||||
|
* path is prepended with this scope's namespace automatically.
|
||||||
|
*
|
||||||
|
* <p>Calling {@link RouteHandle#with} is optional — the route is registered
|
||||||
|
* automatically before the next operation or when the scope consumer returns.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public RouteHandle<FlashScope> register(RequestHandler handler) {
|
||||||
|
return track(new RouteHandle<>(this, explicit -> {
|
||||||
|
List<Middleware> injected = ctx.processors().stream()
|
||||||
|
.flatMap(p -> p.process(handler.getClass()).stream())
|
||||||
|
.toList();
|
||||||
|
Middleware[] all = injected.isEmpty()
|
||||||
|
? explicit
|
||||||
|
: Stream.concat(injected.stream(), Arrays.stream(explicit)).toArray(Middleware[]::new);
|
||||||
|
Route annotation = handler.getClass().getAnnotation(Route.class);
|
||||||
|
if (annotation != null) {
|
||||||
|
String full = ns(annotation.path());
|
||||||
|
emit(annotation.method(), full, handler.getClass(), injected, explicit);
|
||||||
|
router.doRegister(annotation.method(), full, (RequestHandler) handler, all);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans {@code packageName} for {@link RequestHandler} subclasses annotated with
|
||||||
|
* {@link Route @Route}. Each is instantiated via its no-arg constructor and registered
|
||||||
|
* with this scope's namespace prefix and annotation processors applied.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public FlashScope scan(String packageName) {
|
||||||
|
flushPending();
|
||||||
|
PackageScanner.findHandlers(packageName).forEach(cls -> register(instantiate(cls)).ensureRegistered());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FlashScope onException(AbstractRouter.ExceptionHandler h) {
|
||||||
|
flushPending();
|
||||||
|
router.onException(h);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FlashScope onNotFound(SimpleHandler.FunctionalHandler h) {
|
||||||
|
flushPending();
|
||||||
|
router.onNotFound(h);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ExtensionContext ctx() {
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internals ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Ensures any pending route is registered when the scope consumer returns. */
|
||||||
|
void flush() {
|
||||||
|
flushPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the sub-router for GlobalRouter to mount. */
|
||||||
|
AbstractRouter router() {
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prepends this scope's namespace to the given path. */
|
||||||
|
private String ns(String path) {
|
||||||
|
return namespace + PathUtils.sanitize(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static RequestHandler instantiate(Class<?> cls) {
|
||||||
|
try {
|
||||||
|
return (RequestHandler) cls.getDeclaredConstructor().newInstance();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to instantiate handler: " + cls.getName() +
|
||||||
|
" — ensure it has a public no-arg constructor", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits a {@link RouteEvent} to all {@link RouteListener}s visible from this scope's context.
|
||||||
|
* Parent-level listeners (registered on the app) are included via context inheritance.
|
||||||
|
* No-op if no listener has been registered. Never called on the request hot-path.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private void emit(HttpMethod method, String path, Class<?> handlerClass,
|
||||||
|
List<Middleware> injected, Middleware[] explicit) {
|
||||||
|
List<RouteListener> listeners = ctx.routeListeners();
|
||||||
|
if (listeners.isEmpty()) return;
|
||||||
|
Middleware[] routerMws = router.routerMiddlewares();
|
||||||
|
List<Class<? extends Middleware>> chain = new ArrayList<>(routerMws.length + injected.size() + explicit.length);
|
||||||
|
for (Middleware m : routerMws) chain.add((Class<? extends Middleware>) m.getClass());
|
||||||
|
for (Middleware m : injected) chain.add((Class<? extends Middleware>) m.getClass());
|
||||||
|
for (Middleware m : explicit) chain.add((Class<? extends Middleware>) m.getClass());
|
||||||
|
RouteEvent event = new RouteEvent(method, path, namespace,
|
||||||
|
router.getClass().getSimpleName(), handlerClass, List.copyOf(chain));
|
||||||
|
listeners.forEach(l -> l.onRoute(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package dev.relism.extension;
|
||||||
|
|
||||||
|
import dev.relism.models.RequestHandler;
|
||||||
|
import dev.relism.routing.Route;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Enumeration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.jar.JarEntry;
|
||||||
|
import java.util.jar.JarFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal classpath scanner used by {@link FlashApp#scan} and {@link FlashScope#scan}.
|
||||||
|
* Finds all classes in a package that extend {@link RequestHandler} and carry {@link Route @Route}.
|
||||||
|
* Supports both exploded directories (development) and fat JARs (deployment).
|
||||||
|
*/
|
||||||
|
final class PackageScanner {
|
||||||
|
|
||||||
|
private PackageScanner() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all {@link RequestHandler} subclasses in {@code packageName} that carry
|
||||||
|
* {@link Route @Route} and have a public no-arg constructor.
|
||||||
|
*/
|
||||||
|
static List<Class<?>> findHandlers(String packageName) {
|
||||||
|
String resourcePath = packageName.replace('.', '/');
|
||||||
|
ClassLoader cl = Thread.currentThread().getContextClassLoader();
|
||||||
|
List<Class<?>> result = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
Enumeration<URL> resources = cl.getResources(resourcePath);
|
||||||
|
while (resources.hasMoreElements()) {
|
||||||
|
URL url = resources.nextElement();
|
||||||
|
String protocol = url.getProtocol();
|
||||||
|
if ("file".equals(protocol)) {
|
||||||
|
scanDirectory(new File(url.toURI()), packageName, cl, result);
|
||||||
|
} else if ("jar".equals(protocol)) {
|
||||||
|
String jarPath = url.getPath();
|
||||||
|
// jar:file:/path/to/app.jar!/com/example → /path/to/app.jar
|
||||||
|
String filePart = jarPath.substring(jarPath.indexOf("file:") + 5, jarPath.indexOf('!'));
|
||||||
|
try (JarFile jar = new JarFile(filePart)) {
|
||||||
|
scanJar(jar, resourcePath, packageName, cl, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to scan package: " + packageName, e);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void scanDirectory(File dir, String packageName, ClassLoader cl, List<Class<?>> result) {
|
||||||
|
File[] files = dir.listFiles();
|
||||||
|
if (files == null) return;
|
||||||
|
for (File file : files) {
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
scanDirectory(file, packageName + '.' + file.getName(), cl, result);
|
||||||
|
} else if (file.getName().endsWith(".class") && !isAnonymous(file.getName())) {
|
||||||
|
String className = packageName + '.' + file.getName().replace(".class", "");
|
||||||
|
tryLoad(className, cl, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void scanJar(JarFile jar, String resourcePath, String packageName,
|
||||||
|
ClassLoader cl, List<Class<?>> result) {
|
||||||
|
Enumeration<JarEntry> entries = jar.entries();
|
||||||
|
while (entries.hasMoreElements()) {
|
||||||
|
String name = entries.nextElement().getName();
|
||||||
|
if (name.startsWith(resourcePath) && name.endsWith(".class") && !isAnonymous(name)) {
|
||||||
|
String className = name.replace('/', '.').replace(".class", "");
|
||||||
|
tryLoad(className, cl, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns {@code true} for anonymous/synthetic class files whose name segment after
|
||||||
|
* the last {@code $} starts with a digit (e.g. {@code Foo$1.class},
|
||||||
|
* {@code Foo$$Lambda$14.class}). Named static nested classes like
|
||||||
|
* {@code Outer$Inner.class} return {@code false} and are eligible for scanning.
|
||||||
|
*/
|
||||||
|
private static boolean isAnonymous(String fileName) {
|
||||||
|
int dollar = fileName.lastIndexOf('$');
|
||||||
|
if (dollar < 0) return false;
|
||||||
|
// skip past any extra '$' (lambda desugaring may produce '$$Lambda$...')
|
||||||
|
int next = dollar + 1;
|
||||||
|
while (next < fileName.length() && fileName.charAt(next) == '$') next++;
|
||||||
|
return next < fileName.length() && Character.isDigit(fileName.charAt(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void tryLoad(String className, ClassLoader cl, List<Class<?>> result) {
|
||||||
|
try {
|
||||||
|
Class<?> cls = cl.loadClass(className);
|
||||||
|
if (RequestHandler.class.isAssignableFrom(cls)
|
||||||
|
&& cls.isAnnotationPresent(Route.class)
|
||||||
|
&& !java.lang.reflect.Modifier.isAbstract(cls.getModifiers())) {
|
||||||
|
cls.getDeclaredConstructor(); // verify no-arg constructor exists
|
||||||
|
result.add(cls);
|
||||||
|
}
|
||||||
|
} catch (Exception | Error ignored) {
|
||||||
|
// Skip classes that cannot be loaded or don't meet criteria
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package dev.relism.extension;
|
||||||
|
|
||||||
|
import dev.relism.http.HttpMethod;
|
||||||
|
import dev.relism.routing.Middleware;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable snapshot of a route captured at registration time.
|
||||||
|
*
|
||||||
|
* <p>Emitted by {@link FlashApp} and {@link FlashScope} to every registered
|
||||||
|
* {@link RouteListener} before the route is compiled into the routing engine.
|
||||||
|
* All information is derived automatically — no annotations or declarations
|
||||||
|
* are required from the developer.
|
||||||
|
*
|
||||||
|
* <h3>Middleware chain</h3>
|
||||||
|
* {@link #middlewareChain} lists the {@link Middleware} classes outermost-first:
|
||||||
|
* <ol>
|
||||||
|
* <li>Router-level middlewares (set on the router constructor)</li>
|
||||||
|
* <li>Annotation-injected middlewares (e.g. from {@code @Authenticated})</li>
|
||||||
|
* <li>Handler-level explicit middlewares (passed via {@code .with(...)})</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <h3>Handler abstraction chain</h3>
|
||||||
|
* Walk {@link #handlerClass} upward via {@link Class#getSuperclass()} to reconstruct
|
||||||
|
* the full inheritance chain (e.g. {@code EditPostPageHandler → HtmlHandler → RequestHandler}).
|
||||||
|
* Read {@link Class#getAnnotations()} on each level to discover declared pointcuts
|
||||||
|
* ({@code @Authenticated}, {@code @RolesAllowed}, {@code @Route}, etc.).
|
||||||
|
*
|
||||||
|
* @param method HTTP method for this route
|
||||||
|
* @param path full path as declared (including namespace prefix for scoped routes)
|
||||||
|
* @param namespace namespace prefix of the router that owns this route ({@code "/"} for root)
|
||||||
|
* @param routerType simple class name of the owning router implementation
|
||||||
|
* @param handlerClass concrete handler class, or {@code null} for anonymous lambda handlers
|
||||||
|
* @param middlewareChain ordered middleware classes, outermost first
|
||||||
|
*/
|
||||||
|
public record RouteEvent(
|
||||||
|
HttpMethod method,
|
||||||
|
String path,
|
||||||
|
String namespace,
|
||||||
|
String routerType,
|
||||||
|
Class<?> handlerClass,
|
||||||
|
List<Class<? extends Middleware>> middlewareChain
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package dev.relism.extension;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observer notified once for each route registered on a {@link FlashApp} or {@link FlashScope}.
|
||||||
|
*
|
||||||
|
* <p>Register via {@link ExtensionContext#addRouteListener}. The listener is called
|
||||||
|
* <em>once per route at boot time</em>, before the route is handed to the routing engine.
|
||||||
|
* There is zero overhead on the request hot-path.
|
||||||
|
*
|
||||||
|
* <p>Intended for extensions that need to introspect the route graph (e.g. a route viewer,
|
||||||
|
* OpenAPI schema builder, etc.) without polluting the routing or middleware infrastructure.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface RouteListener {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once for every registered route, in registration order.
|
||||||
|
*
|
||||||
|
* @param event immutable snapshot of the route's metadata at registration time
|
||||||
|
*/
|
||||||
|
void onRoute(RouteEvent event);
|
||||||
|
}
|
||||||
@@ -99,6 +99,37 @@ public class Response {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 302 Found redirect. Clears the body, sets status and {@code Location} header.
|
||||||
|
* Encoded once at call time; zero-alloc on the write path.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* return res.redirect("/login");
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public Response redirect(String url) {
|
||||||
|
return redirect(HttpStatus.FOUND, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect with an explicit 3xx status. Use {@link HttpStatus#MOVED_PERMANENTLY},
|
||||||
|
* {@link HttpStatus#TEMPORARY_REDIRECT} (307), or {@link HttpStatus#PERMANENT_REDIRECT} (308)
|
||||||
|
* when semantics matter.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* return res.redirect(HttpStatus.MOVED_PERMANENTLY, "/new-path");
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public Response redirect(HttpStatus status, String url) {
|
||||||
|
this.statusCode = status.code();
|
||||||
|
this.statusBytes = status.bytes();
|
||||||
|
this.body = null;
|
||||||
|
this.stream = null;
|
||||||
|
if (headers == null) headers = new ArrayList<>();
|
||||||
|
headers.add(("Location: " + url + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/** Adds a response header. Encoded once at call time; zero-alloc on the write path. */
|
/** Adds a response header. Encoded once at call time; zero-alloc on the write path. */
|
||||||
public Response header(String name, String value) {
|
public Response header(String name, String value) {
|
||||||
if (headers == null) headers = new ArrayList<>();
|
if (headers == null) headers = new ArrayList<>();
|
||||||
|
|||||||
@@ -17,11 +17,15 @@ import java.nio.charset.StandardCharsets;
|
|||||||
*
|
*
|
||||||
* <p>Router-level {@link Middleware middlewares} are passed at construction time and
|
* <p>Router-level {@link Middleware middlewares} are passed at construction time and
|
||||||
* pre-fused into a single wrapper applied to every handler registered on this router.
|
* pre-fused into a single wrapper applied to every handler registered on this router.
|
||||||
* Handler-level middlewares are passed to the {@link #register} / {@link #get} family of
|
* Handler-level middlewares are passed to {@link #doRegister} and wrapped
|
||||||
* methods and wrapped <em>inside</em> the router-level chain.
|
* <em>inside</em> the router-level chain.
|
||||||
*
|
*
|
||||||
* <p>All middleware composition happens once at boot time; the hot-path sees only a plain
|
* <p>All middleware composition happens once at boot time; the hot-path sees only a plain
|
||||||
* {@link RequestHandler} call with zero allocation and zero lookup overhead.
|
* {@link RequestHandler} call with zero allocation and zero lookup overhead.
|
||||||
|
*
|
||||||
|
* <p><b>Registration</b>: use {@link dev.relism.extension.FlashApp} or
|
||||||
|
* {@link dev.relism.extension.FlashScope} — they are the public registration API.
|
||||||
|
* {@link #doRegister} is an infrastructure method for those entry points.
|
||||||
*/
|
*/
|
||||||
public abstract class AbstractRouter {
|
public abstract class AbstractRouter {
|
||||||
|
|
||||||
@@ -37,6 +41,13 @@ public abstract class AbstractRouter {
|
|||||||
*/
|
*/
|
||||||
private final Middleware routerMiddleware;
|
private final Middleware routerMiddleware;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw router-level middleware array, kept for route event emission by
|
||||||
|
* {@link dev.relism.extension.FlashApp} and {@link dev.relism.extension.FlashScope}.
|
||||||
|
* Never mutated after construction. Not used on the hot-path.
|
||||||
|
*/
|
||||||
|
private final Middleware[] rawRouterMiddlewares;
|
||||||
|
|
||||||
protected SimpleHandler notFoundHandler = new SimpleHandler((req, res) -> {
|
protected SimpleHandler notFoundHandler = new SimpleHandler((req, res) -> {
|
||||||
res.setStatusCode(404);
|
res.setStatusCode(404);
|
||||||
res.setContentType(ContentType.TEXT_HTML);
|
res.setContentType(ContentType.TEXT_HTML);
|
||||||
@@ -56,15 +67,25 @@ public abstract class AbstractRouter {
|
|||||||
* @param middlewares zero or more middlewares applied to every handler on this router
|
* @param middlewares zero or more middlewares applied to every handler on this router
|
||||||
*/
|
*/
|
||||||
protected AbstractRouter(Middleware... middlewares) {
|
protected AbstractRouter(Middleware... middlewares) {
|
||||||
|
this.rawRouterMiddlewares = middlewares;
|
||||||
this.routerMiddleware = middlewares.length == 0 ? null
|
this.routerMiddleware = middlewares.length == 0 ? null
|
||||||
: middlewares.length == 1 ? middlewares[0]
|
: middlewares.length == 1 ? middlewares[0]
|
||||||
: Middleware.of(middlewares);
|
: Middleware.of(middlewares);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the raw router-level middleware array as passed at construction.
|
||||||
|
* Used by {@link dev.relism.extension.FlashApp} and {@link dev.relism.extension.FlashScope}
|
||||||
|
* to populate {@link dev.relism.extension.RouteEvent#middlewareChain()}.
|
||||||
|
* Never mutated; never called on the hot-path.
|
||||||
|
*/
|
||||||
|
public Middleware[] routerMiddlewares() { return rawRouterMiddlewares; }
|
||||||
|
|
||||||
// ── Internal wiring ───────────────────────────────────────────────────────
|
// ── Internal wiring ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
SimpleHandler getNotFoundHandler() { return notFoundHandler; }
|
SimpleHandler getNotFoundHandler() { return notFoundHandler; }
|
||||||
ExceptionHandler getExceptionHandler() { return exceptionHandler; }
|
ExceptionHandler getExceptionHandler() { return exceptionHandler; }
|
||||||
|
|
||||||
void setNamespace(String namespace) {
|
void setNamespace(String namespace) {
|
||||||
this.namespace = namespace;
|
this.namespace = namespace;
|
||||||
this.namespaceBytes = namespace.getBytes(StandardCharsets.UTF_8);
|
this.namespaceBytes = namespace.getBytes(StandardCharsets.UTF_8);
|
||||||
@@ -81,10 +102,8 @@ public abstract class AbstractRouter {
|
|||||||
*/
|
*/
|
||||||
private RequestHandler compile(RequestHandler handler, Middleware[] handlerMiddlewares) {
|
private RequestHandler compile(RequestHandler handler, Middleware[] handlerMiddlewares) {
|
||||||
RequestHandler compiled = handler;
|
RequestHandler compiled = handler;
|
||||||
// handler-level: wrap from innermost outward; box FunctionalHandler → SimpleHandler once per layer
|
|
||||||
for (int i = handlerMiddlewares.length - 1; i >= 0; i--)
|
for (int i = handlerMiddlewares.length - 1; i >= 0; i--)
|
||||||
compiled = new SimpleHandler(handlerMiddlewares[i].wrap(compiled));
|
compiled = new SimpleHandler(handlerMiddlewares[i].wrap(compiled));
|
||||||
// router-level: outermost wrapper, executes first
|
|
||||||
if (routerMiddleware != null)
|
if (routerMiddleware != null)
|
||||||
compiled = new SimpleHandler(routerMiddleware.wrap(compiled));
|
compiled = new SimpleHandler(routerMiddleware.wrap(compiled));
|
||||||
return compiled;
|
return compiled;
|
||||||
@@ -102,11 +121,12 @@ public abstract class AbstractRouter {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Internal registration (package-private) ───────────────────────────────
|
// ── Infrastructure registration ───────────────────────────────────────────
|
||||||
|
// Used by FlashApp and FlashScope. Not part of the public user-facing API.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a lambda handler immediately with a pre-built middleware array.
|
* Registers a lambda handler immediately with a pre-built middleware array.
|
||||||
* Called by {@link RouteHandle#with} and by {@link dev.relism.extension.FlashApp}.
|
* Infrastructure method — use {@link dev.relism.extension.FlashApp} instead.
|
||||||
*/
|
*/
|
||||||
public AbstractRouter doRegister(HttpMethod method, String path,
|
public AbstractRouter doRegister(HttpMethod method, String path,
|
||||||
SimpleHandler.FunctionalHandler handler, Middleware[] middlewares) {
|
SimpleHandler.FunctionalHandler handler, Middleware[] middlewares) {
|
||||||
@@ -116,7 +136,8 @@ public abstract class AbstractRouter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a class-based handler immediately with a pre-built middleware array.
|
* Registers a class-based handler immediately with a pre-built middleware array.
|
||||||
* Called by {@link RouteHandle#with} and by {@link dev.relism.extension.FlashApp}.
|
* Reads {@link Route @Route} for method and path.
|
||||||
|
* Infrastructure method — use {@link dev.relism.extension.FlashApp} instead.
|
||||||
*/
|
*/
|
||||||
public AbstractRouter doRegister(RequestHandler handler, Middleware[] middlewares) {
|
public AbstractRouter doRegister(RequestHandler handler, Middleware[] middlewares) {
|
||||||
Route annotation = handler.getClass().getAnnotation(Route.class);
|
Route annotation = handler.getClass().getAnnotation(Route.class);
|
||||||
@@ -126,42 +147,15 @@ public abstract class AbstractRouter {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lambda handler registration ───────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Begins registration of a lambda handler. Call {@link RouteHandle#with} on the
|
* Registers a class-based handler with an explicit method and path (ignoring the
|
||||||
* returned handle to supply middlewares (if any) and complete the registration.
|
* {@link Route @Route} annotation's path). Used by {@link dev.relism.extension.FlashScope}
|
||||||
*
|
* to prepend the scope's namespace prefix.
|
||||||
* <pre>{@code
|
* Infrastructure method — use {@link dev.relism.extension.FlashScope} instead.
|
||||||
* router.get("/ping", (req, res) -> "pong").with();
|
|
||||||
* router.get("/admin", adminHandler).with(auth, logging);
|
|
||||||
* }</pre>
|
|
||||||
*/
|
*/
|
||||||
public RouteHandle<AbstractRouter> get (String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.GET, path, h, m)); }
|
public AbstractRouter doRegister(HttpMethod method, String path,
|
||||||
public RouteHandle<AbstractRouter> post (String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.POST, path, h, m)); }
|
RequestHandler handler, Middleware[] middlewares) {
|
||||||
public RouteHandle<AbstractRouter> put (String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.PUT, path, h, m)); }
|
return addRoute(method, PathUtils.sanitize(path), compile(handler, middlewares));
|
||||||
public RouteHandle<AbstractRouter> delete (String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.DELETE, path, h, m)); }
|
|
||||||
public RouteHandle<AbstractRouter> patch (String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.PATCH, path, h, m)); }
|
|
||||||
public RouteHandle<AbstractRouter> options(String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.OPTIONS, path, h, m)); }
|
|
||||||
public RouteHandle<AbstractRouter> head (String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.HEAD, path, h, m)); }
|
|
||||||
public RouteHandle<AbstractRouter> trace (String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.TRACE, path, h, m)); }
|
|
||||||
public RouteHandle<AbstractRouter> connect(String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.CONNECT, path, h, m)); }
|
|
||||||
public RouteHandle<AbstractRouter> purge (String path, SimpleHandler.FunctionalHandler h) { return new RouteHandle<>(this, m -> doRegister(HttpMethod.PURGE, path, h, m)); }
|
|
||||||
|
|
||||||
// ── Class-based handler registration ─────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Begins registration of a class-based handler. The class must carry a
|
|
||||||
* {@link Route @Route} annotation. Call {@link RouteHandle#with} to supply
|
|
||||||
* optional extra middlewares and complete the registration.
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* router.register(new BlogHandler()).with();
|
|
||||||
* router.register(new AdminHandler()).with(logging);
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public RouteHandle<AbstractRouter> register(RequestHandler handler) {
|
|
||||||
return new RouteHandle<>(this, m -> doRegister(handler, m));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Routing ───────────────────────────────────────────────────────────────
|
// ── Routing ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package dev.relism;
|
package dev.relism;
|
||||||
|
|
||||||
|
import dev.relism.extension.FlashApp;
|
||||||
|
import dev.relism.extension.FlashConfiguration;
|
||||||
import dev.relism.http.ContentType;
|
import dev.relism.http.ContentType;
|
||||||
import dev.relism.models.Response;
|
import dev.relism.models.Response;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
@@ -23,7 +25,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
|
|
||||||
class HttpServerConcurrencyTest {
|
class HttpServerConcurrencyTest {
|
||||||
|
|
||||||
private HttpServer server;
|
private FlashApp app;
|
||||||
private int port;
|
private int port;
|
||||||
private HttpClient httpClient;
|
private HttpClient httpClient;
|
||||||
|
|
||||||
@@ -32,19 +34,19 @@ class HttpServerConcurrencyTest {
|
|||||||
try (ServerSocket s = new ServerSocket(0)) {
|
try (ServerSocket s = new ServerSocket(0)) {
|
||||||
port = s.getLocalPort();
|
port = s.getLocalPort();
|
||||||
}
|
}
|
||||||
HttpServerConfiguration config = HttpServerConfiguration.builder()
|
|
||||||
|
app = FlashApp.create(FlashConfiguration.builder()
|
||||||
.port(port)
|
.port(port)
|
||||||
.host("127.0.0.1")
|
.host("127.0.0.1")
|
||||||
.build();
|
.build());
|
||||||
|
|
||||||
server = new HttpServer(config);
|
app.get("/ping", (req, res) -> "pong");
|
||||||
server.get("/ping", (req, res) -> "pong").with();
|
app.post("/echo", (req, res) -> {
|
||||||
server.post("/echo", (req, res) -> {
|
|
||||||
byte[] body = req.body().bytes();
|
byte[] body = req.body().bytes();
|
||||||
return new Response(200, body, ContentType.TEXT_PLAIN);
|
return new Response(200, body, ContentType.TEXT_PLAIN);
|
||||||
}).with();
|
});
|
||||||
|
|
||||||
server.start().get(5, TimeUnit.SECONDS);
|
app.start().get(5, TimeUnit.SECONDS);
|
||||||
|
|
||||||
httpClient = HttpClient.newBuilder()
|
httpClient = HttpClient.newBuilder()
|
||||||
.version(HttpClient.Version.HTTP_1_1)
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
@@ -53,8 +55,7 @@ class HttpServerConcurrencyTest {
|
|||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
void tearDown() {
|
void tearDown() {
|
||||||
if (server != null)
|
if (app != null) app.stop();
|
||||||
server.stop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
@@ -91,7 +92,6 @@ class HttpServerConcurrencyTest {
|
|||||||
CountDownLatch start = new CountDownLatch(1);
|
CountDownLatch start = new CountDownLatch(1);
|
||||||
AtomicInteger successes = new AtomicInteger();
|
AtomicInteger successes = new AtomicInteger();
|
||||||
List<Throwable> errors = new CopyOnWriteArrayList<>();
|
List<Throwable> errors = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
List<String> responses = new CopyOnWriteArrayList<>();
|
List<String> responses = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
@@ -100,11 +100,9 @@ class HttpServerConcurrencyTest {
|
|||||||
try {
|
try {
|
||||||
start.await();
|
start.await();
|
||||||
HttpResponse<String> res = get("/ping");
|
HttpResponse<String> res = get("/ping");
|
||||||
String summary = res.statusCode() + "|" + res.body();
|
responses.add(res.statusCode() + "|" + res.body());
|
||||||
responses.add(summary);
|
if (res.statusCode() == 200 && "pong".equals(res.body()))
|
||||||
if (res.statusCode() == 200 && "pong".equals(res.body())) {
|
|
||||||
successes.incrementAndGet();
|
successes.incrementAndGet();
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
errors.add(e);
|
errors.add(e);
|
||||||
}
|
}
|
||||||
@@ -169,7 +167,7 @@ class HttpServerConcurrencyTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Races the router's lazy-compile step: a fresh server with 10 registered routes
|
* Races the router's lazy-compile step: a fresh app with 10 registered routes
|
||||||
* is hit by threads simultaneously before any request has been processed,
|
* is hit by threads simultaneously before any request has been processed,
|
||||||
* causing multiple threads to compete on the first compilation.
|
* causing multiple threads to compete on the first compilation.
|
||||||
*/
|
*/
|
||||||
@@ -180,17 +178,17 @@ class HttpServerConcurrencyTest {
|
|||||||
freshPort = s.getLocalPort();
|
freshPort = s.getLocalPort();
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpServer freshServer = new HttpServer(HttpServerConfiguration.builder()
|
FlashApp freshApp = FlashApp.create(FlashConfiguration.builder()
|
||||||
.port(freshPort)
|
.port(freshPort)
|
||||||
.host("127.0.0.1")
|
.host("127.0.0.1")
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
for (int i = 0; i < 10; i++) {
|
for (int i = 0; i < 10; i++) {
|
||||||
final int idx = i;
|
final int idx = i;
|
||||||
freshServer.get("/route" + idx, (req, res) -> "handler" + idx).with();
|
freshApp.get("/route" + idx, (req, res) -> "handler" + idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
freshServer.start().get(5, TimeUnit.SECONDS);
|
freshApp.start().get(5, TimeUnit.SECONDS);
|
||||||
|
|
||||||
int count = 20;
|
int count = 20;
|
||||||
ExecutorService pool = Executors.newFixedThreadPool(count);
|
ExecutorService pool = Executors.newFixedThreadPool(count);
|
||||||
@@ -206,9 +204,8 @@ class HttpServerConcurrencyTest {
|
|||||||
try {
|
try {
|
||||||
start.await();
|
start.await();
|
||||||
HttpResponse<String> res = get(freshPort, "/route" + routeIdx);
|
HttpResponse<String> res = get(freshPort, "/route" + routeIdx);
|
||||||
if (res.statusCode() == 200 && ("handler" + routeIdx).equals(res.body())) {
|
if (res.statusCode() == 200 && ("handler" + routeIdx).equals(res.body()))
|
||||||
successes.incrementAndGet();
|
successes.incrementAndGet();
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
errors.add(e);
|
errors.add(e);
|
||||||
}
|
}
|
||||||
@@ -224,7 +221,7 @@ class HttpServerConcurrencyTest {
|
|||||||
assertTrue(errors.isEmpty(), () -> "Unexpected errors: " + errors);
|
assertTrue(errors.isEmpty(), () -> "Unexpected errors: " + errors);
|
||||||
assertEquals(count, successes.get());
|
assertEquals(count, successes.get());
|
||||||
} finally {
|
} finally {
|
||||||
freshServer.stop();
|
freshApp.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package dev.relism;
|
package dev.relism;
|
||||||
|
|
||||||
|
import dev.relism.extension.FlashApp;
|
||||||
|
import dev.relism.extension.FlashConfiguration;
|
||||||
import dev.relism.http.ContentType;
|
import dev.relism.http.ContentType;
|
||||||
import dev.relism.models.Response;
|
import dev.relism.models.Response;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
@@ -19,58 +21,52 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
|
|
||||||
class HttpServerTest {
|
class HttpServerTest {
|
||||||
|
|
||||||
private HttpServer server;
|
private FlashApp app;
|
||||||
private int port;
|
private int port;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() throws Exception {
|
void setUp() throws Exception {
|
||||||
// Find a free ephemeral port
|
|
||||||
try (ServerSocket s = new ServerSocket(0)) {
|
try (ServerSocket s = new ServerSocket(0)) {
|
||||||
port = s.getLocalPort();
|
port = s.getLocalPort();
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpServerConfiguration config = HttpServerConfiguration.builder()
|
app = FlashApp.create(FlashConfiguration.builder()
|
||||||
.port(port)
|
.port(port)
|
||||||
.host("127.0.0.1")
|
.host("127.0.0.1")
|
||||||
.build();
|
.build());
|
||||||
|
|
||||||
server = new HttpServer(config);
|
app.get("/api/ping", (req, res) -> "pong");
|
||||||
|
|
||||||
// Setup some routes
|
app.post("/api/echo", (req, res) -> {
|
||||||
server.get("/api/ping", (req, res) -> "pong").with();
|
|
||||||
|
|
||||||
server.post("/api/echo", (req, res) -> {
|
|
||||||
byte[] body = req.body().bytes();
|
byte[] body = req.body().bytes();
|
||||||
return res.status(201).body(body); // echo body & change status
|
return res.status(201).body(body);
|
||||||
}).with();
|
});
|
||||||
|
|
||||||
server.get("/api/crash", (req, res) -> {
|
app.get("/api/crash", (req, res) -> {
|
||||||
throw new RuntimeException("Simulated Crash");
|
throw new RuntimeException("Simulated Crash");
|
||||||
}).with();
|
});
|
||||||
|
|
||||||
byte[] streamData = "streaming response body".getBytes(StandardCharsets.UTF_8);
|
byte[] streamData = "streaming response body".getBytes(StandardCharsets.UTF_8);
|
||||||
server.get("/api/stream", (req, res) ->
|
app.get("/api/stream", (req, res) ->
|
||||||
res.stream(new ByteArrayInputStream(streamData), streamData.length)).with();
|
res.stream(new ByteArrayInputStream(streamData), streamData.length));
|
||||||
|
|
||||||
server.get("/api/chunked-out", (req, res) ->
|
app.get("/api/chunked-out", (req, res) ->
|
||||||
res.chunked(new ByteArrayInputStream(streamData))).with();
|
res.chunked(new ByteArrayInputStream(streamData)));
|
||||||
|
|
||||||
server.get("/api/custom-header", (req, res) ->
|
app.get("/api/custom-header", (req, res) ->
|
||||||
res.body("ok").header("X-Flash", "works")).with();
|
res.body("ok").header("X-Flash", "works"));
|
||||||
|
|
||||||
server.post("/api/chunked-in", (req, res) -> {
|
app.post("/api/chunked-in", (req, res) -> {
|
||||||
byte[] body = req.body().bytes();
|
byte[] body = req.body().bytes();
|
||||||
return res.body(body);
|
return res.body(body);
|
||||||
}).with();
|
});
|
||||||
|
|
||||||
server.start().get(5, TimeUnit.SECONDS);
|
app.start().get(5, TimeUnit.SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
void tearDown() {
|
void tearDown() {
|
||||||
if (server != null) {
|
if (app != null) app.stop();
|
||||||
server.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
@@ -125,7 +121,7 @@ class HttpServerTest {
|
|||||||
int size = Integer.parseInt(sizeLine.toString().trim(), 16);
|
int size = Integer.parseInt(sizeLine.toString().trim(), 16);
|
||||||
if (size == 0) break;
|
if (size == 0) break;
|
||||||
result.write(in.readNBytes(size));
|
result.write(in.readNBytes(size));
|
||||||
in.read(); in.read(); // \r\n after chunk data
|
in.read(); in.read();
|
||||||
}
|
}
|
||||||
return result.toString(StandardCharsets.UTF_8);
|
return result.toString(StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
@@ -134,28 +130,18 @@ class HttpServerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testGet_pingRoute_returns200AndStringBody() throws Exception {
|
void testGet_pingRoute_returns200AndStringBody() throws Exception {
|
||||||
String req = "GET /api/ping HTTP/1.1\r\n" +
|
String req = "GET /api/ping HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||||
"Host: localhost\r\n" +
|
|
||||||
"\r\n";
|
|
||||||
|
|
||||||
String res = sendRawRequest(req);
|
String res = sendRawRequest(req);
|
||||||
|
|
||||||
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
|
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
|
||||||
assertTrue(res.contains("Content-Length: 4")); // "pong"
|
assertTrue(res.contains("Content-Length: 4"));
|
||||||
assertTrue(res.endsWith("pong"));
|
assertTrue(res.endsWith("pong"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testPost_echoRoute_returns201AndEchoesBody() throws Exception {
|
void testPost_echoRoute_returns201AndEchoesBody() throws Exception {
|
||||||
String body = "Hello, Flash!";
|
String body = "Hello, Flash!";
|
||||||
String req = "POST /api/echo HTTP/1.1\r\n" +
|
String req = "POST /api/echo HTTP/1.1\r\nHost: localhost\r\nContent-Length: " + body.length() + "\r\n\r\n" + body;
|
||||||
"Host: localhost\r\n" +
|
|
||||||
"Content-Length: " + body.length() + "\r\n" +
|
|
||||||
"\r\n" +
|
|
||||||
body;
|
|
||||||
|
|
||||||
String res = sendRawRequest(req);
|
String res = sendRawRequest(req);
|
||||||
|
|
||||||
assertTrue(res.startsWith("HTTP/1.1 201 Created"));
|
assertTrue(res.startsWith("HTTP/1.1 201 Created"));
|
||||||
assertTrue(res.contains("Content-Length: " + body.length()));
|
assertTrue(res.contains("Content-Length: " + body.length()));
|
||||||
assertTrue(res.endsWith(body));
|
assertTrue(res.endsWith(body));
|
||||||
@@ -163,12 +149,8 @@ class HttpServerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testNotFound_returns404Html() throws Exception {
|
void testNotFound_returns404Html() throws Exception {
|
||||||
String req = "GET /api/unknown HTTP/1.1\r\n" +
|
String req = "GET /api/unknown HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||||
"Host: localhost\r\n" +
|
|
||||||
"\r\n";
|
|
||||||
|
|
||||||
String res = sendRawRequest(req);
|
String res = sendRawRequest(req);
|
||||||
|
|
||||||
assertTrue(res.startsWith("HTTP/1.1 404 Not Found"));
|
assertTrue(res.startsWith("HTTP/1.1 404 Not Found"));
|
||||||
assertTrue(res.contains("404"));
|
assertTrue(res.contains("404"));
|
||||||
assertTrue(res.contains("No route matched this request"));
|
assertTrue(res.contains("No route matched this request"));
|
||||||
@@ -176,12 +158,8 @@ class HttpServerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testException_returns500Html() throws Exception {
|
void testException_returns500Html() throws Exception {
|
||||||
String req = "GET /api/crash HTTP/1.1\r\n" +
|
String req = "GET /api/crash HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||||
"Host: localhost\r\n" +
|
|
||||||
"\r\n";
|
|
||||||
|
|
||||||
String res = sendRawRequest(req);
|
String res = sendRawRequest(req);
|
||||||
|
|
||||||
assertTrue(res.startsWith("HTTP/1.1 500 Internal Server Error"));
|
assertTrue(res.startsWith("HTTP/1.1 500 Internal Server Error"));
|
||||||
assertTrue(res.contains("500"));
|
assertTrue(res.contains("500"));
|
||||||
assertTrue(res.contains("Simulated Crash"));
|
assertTrue(res.contains("Simulated Crash"));
|
||||||
@@ -189,25 +167,18 @@ class HttpServerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testRoot_returns404_noExceptionTossed() throws Exception {
|
void testRoot_returns404_noExceptionTossed() throws Exception {
|
||||||
String req = "GET / HTTP/1.1\r\n" +
|
String req = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||||
"Host: localhost\r\n" +
|
|
||||||
"\r\n";
|
|
||||||
|
|
||||||
String res = sendRawRequest(req);
|
String res = sendRawRequest(req);
|
||||||
|
|
||||||
assertTrue(res.startsWith("HTTP/1.1 404 Not Found"));
|
assertTrue(res.startsWith("HTTP/1.1 404 Not Found"));
|
||||||
assertTrue(res.contains("No route matched this request."));
|
assertTrue(res.contains("No route matched this request."));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- streaming response ---
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testStreamingResponse_writesContentLengthAndBody() throws Exception {
|
void testStreamingResponse_writesContentLengthAndBody() throws Exception {
|
||||||
String req = "GET /api/stream HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
String req = "GET /api/stream HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||||
String res = sendRawRequest(req);
|
String res = sendRawRequest(req);
|
||||||
|
|
||||||
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
|
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
|
||||||
assertTrue(res.contains("Content-Length: 23")); // "streaming response body"
|
assertTrue(res.contains("Content-Length: 23"));
|
||||||
assertTrue(res.endsWith("streaming response body"));
|
assertTrue(res.endsWith("streaming response body"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,41 +186,28 @@ class HttpServerTest {
|
|||||||
void testChunkedResponse_writesTransferEncodingChunked() throws Exception {
|
void testChunkedResponse_writesTransferEncodingChunked() throws Exception {
|
||||||
String req = "GET /api/chunked-out HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
String req = "GET /api/chunked-out HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||||
String res = sendRawRequest(req);
|
String res = sendRawRequest(req);
|
||||||
|
|
||||||
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
|
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
|
||||||
assertTrue(res.contains("Transfer-Encoding: chunked"));
|
assertTrue(res.contains("Transfer-Encoding: chunked"));
|
||||||
assertTrue(res.endsWith("streaming response body"));
|
assertTrue(res.endsWith("streaming response body"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- custom response headers ---
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testCustomHeader_appearsInResponse() throws Exception {
|
void testCustomHeader_appearsInResponse() throws Exception {
|
||||||
String req = "GET /api/custom-header HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
String req = "GET /api/custom-header HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||||
String res = sendRawRequest(req);
|
String res = sendRawRequest(req);
|
||||||
|
|
||||||
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
|
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
|
||||||
assertTrue(res.contains("X-Flash: works\r\n"));
|
assertTrue(res.contains("X-Flash: works\r\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- chunked request body ---
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testChunkedRequestBody_decodedAndEchoed() throws Exception {
|
void testChunkedRequestBody_decodedAndEchoed() throws Exception {
|
||||||
String req = "POST /api/chunked-in HTTP/1.1\r\n" +
|
String req = "POST /api/chunked-in HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n"
|
||||||
"Host: localhost\r\n" +
|
+ "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n";
|
||||||
"Transfer-Encoding: chunked\r\n" +
|
|
||||||
"\r\n" +
|
|
||||||
"5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n";
|
|
||||||
|
|
||||||
String res = sendRawRequest(req);
|
String res = sendRawRequest(req);
|
||||||
|
|
||||||
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
|
assertTrue(res.startsWith("HTTP/1.1 200 OK"));
|
||||||
assertTrue(res.endsWith("hello world"));
|
assertTrue(res.endsWith("hello world"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- keep-alive ---
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testKeepAlive_twoRequestsOnSameConnection() throws Exception {
|
void testKeepAlive_twoRequestsOnSameConnection() throws Exception {
|
||||||
String req = "GET /api/ping HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
String req = "GET /api/ping HTTP/1.1\r\nHost: localhost\r\n\r\n";
|
||||||
@@ -257,7 +215,6 @@ class HttpServerTest {
|
|||||||
try (Socket socket = new Socket("127.0.0.1", port);
|
try (Socket socket = new Socket("127.0.0.1", port);
|
||||||
OutputStream out = socket.getOutputStream();
|
OutputStream out = socket.getOutputStream();
|
||||||
InputStream in = socket.getInputStream()) {
|
InputStream in = socket.getInputStream()) {
|
||||||
|
|
||||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||||
out.write(req.getBytes(StandardCharsets.UTF_8));
|
out.write(req.getBytes(StandardCharsets.UTF_8));
|
||||||
out.write(req.getBytes(StandardCharsets.UTF_8));
|
out.write(req.getBytes(StandardCharsets.UTF_8));
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
|
|
||||||
class AbstractRouterTest {
|
class AbstractRouterTest {
|
||||||
|
|
||||||
// A dummy router for testing base functionality
|
// A minimal concrete router for testing base-class functionality
|
||||||
static class DummyRouter extends AbstractRouter {
|
static class DummyRouter extends AbstractRouter {
|
||||||
RequestHandler lastAddedHandler;
|
RequestHandler lastAddedHandler;
|
||||||
HttpMethod lastAddedMethod;
|
HttpMethod lastAddedMethod;
|
||||||
@@ -22,7 +22,7 @@ class AbstractRouterTest {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RequestHandler route(Request request) {
|
public RequestHandler route(Request request) {
|
||||||
return null; // Not testing routing logic here
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -34,21 +34,6 @@ class AbstractRouterTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Route(method = dev.relism.http.HttpMethod.POST, path = "/profile")
|
|
||||||
static class ProfileHandler extends RequestHandler {
|
|
||||||
@Override
|
|
||||||
public Object handle(Request request, Response response) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static class UnannotatedHandler extends RequestHandler {
|
|
||||||
@Override
|
|
||||||
public Object handle(Request request, Response response) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- namespace ---
|
// --- namespace ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -61,37 +46,40 @@ class AbstractRouterTest {
|
|||||||
assertArrayEquals("/api".getBytes(StandardCharsets.UTF_8), router.getNamespaceBytes());
|
assertArrayEquals("/api".getBytes(StandardCharsets.UTF_8), router.getNamespaceBytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- doRegister (infrastructure method used by FlashApp/FlashScope) ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getPostPutDelete_delegatesToAddRouteWithSanitizedPath() {
|
void doRegister_lambda_sanitizesPathAndWrapsHandler() {
|
||||||
DummyRouter router = new DummyRouter();
|
DummyRouter router = new DummyRouter();
|
||||||
SimpleHandler.FunctionalHandler func = (req, res) -> "OK";
|
SimpleHandler.FunctionalHandler func = (req, res) -> "OK";
|
||||||
|
|
||||||
router.get("users/", func).with();
|
router.doRegister(HttpMethod.GET, "users/", func, new Middleware[0]);
|
||||||
assertEquals(HttpMethod.GET, router.lastAddedMethod);
|
assertEquals(HttpMethod.GET, router.lastAddedMethod);
|
||||||
assertEquals("/users", router.lastAddedPath);
|
assertEquals("/users", router.lastAddedPath);
|
||||||
assertTrue(router.lastAddedHandler instanceof SimpleHandler);
|
assertNotNull(router.lastAddedHandler);
|
||||||
|
|
||||||
router.post("/items", func).with();
|
router.doRegister(HttpMethod.DELETE, "//delete//", func, new Middleware[0]);
|
||||||
assertEquals(HttpMethod.POST, router.lastAddedMethod);
|
|
||||||
|
|
||||||
router.put("update", func).with();
|
|
||||||
assertEquals(HttpMethod.PUT, router.lastAddedMethod);
|
|
||||||
|
|
||||||
router.delete("//delete//", func).with();
|
|
||||||
assertEquals(HttpMethod.DELETE, router.lastAddedMethod);
|
assertEquals(HttpMethod.DELETE, router.lastAddedMethod);
|
||||||
assertEquals("/delete", router.lastAddedPath);
|
assertEquals("/delete", router.lastAddedPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- register ---
|
@Route(method = HttpMethod.POST, path = "/profile")
|
||||||
|
static class ProfileHandler extends RequestHandler {
|
||||||
|
@Override
|
||||||
|
public Object handle(Request request, Response response) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
static class UnannotatedHandler extends RequestHandler {
|
||||||
|
@Override
|
||||||
|
public Object handle(Request request, Response response) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void register_annotatedHandler_addsRoute() {
|
void doRegister_annotatedHandler_addsRoute() {
|
||||||
DummyRouter router = new DummyRouter();
|
DummyRouter router = new DummyRouter();
|
||||||
ProfileHandler handler = new ProfileHandler();
|
ProfileHandler handler = new ProfileHandler();
|
||||||
|
|
||||||
router.register(handler).with();
|
router.doRegister(handler, new Middleware[0]);
|
||||||
|
|
||||||
assertEquals(HttpMethod.POST, router.lastAddedMethod);
|
assertEquals(HttpMethod.POST, router.lastAddedMethod);
|
||||||
assertEquals("/profile", router.lastAddedPath);
|
assertEquals("/profile", router.lastAddedPath);
|
||||||
@@ -99,13 +87,13 @@ class AbstractRouterTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void register_unannotatedHandler_doesNothing() {
|
void doRegister_unannotatedHandler_doesNothing() {
|
||||||
DummyRouter router = new DummyRouter();
|
DummyRouter router = new DummyRouter();
|
||||||
router.register(new UnannotatedHandler()).with();
|
router.doRegister(new UnannotatedHandler(), new Middleware[0]);
|
||||||
assertNull(router.lastAddedMethod); // Nothing added
|
assertNull(router.lastAddedMethod);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- default handlers ---
|
// --- error handlers ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void defaultNotFoundHandler_returns404Html() throws Exception {
|
void defaultNotFoundHandler_returns404Html() throws Exception {
|
||||||
@@ -114,9 +102,7 @@ class AbstractRouterTest {
|
|||||||
|
|
||||||
assertNotNull(router.getNotFoundHandler());
|
assertNotNull(router.getNotFoundHandler());
|
||||||
|
|
||||||
SimpleHandler.FunctionalHandler custom = (req, resp) -> "Custom 404";
|
router.onNotFound((req, resp) -> "Custom 404");
|
||||||
router.onNotFound(custom);
|
|
||||||
|
|
||||||
assertEquals("Custom 404", router.getNotFoundHandler().handle(null, res));
|
assertEquals("Custom 404", router.getNotFoundHandler().handle(null, res));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,9 +111,7 @@ class AbstractRouterTest {
|
|||||||
DummyRouter router = new DummyRouter();
|
DummyRouter router = new DummyRouter();
|
||||||
assertNotNull(router.getExceptionHandler());
|
assertNotNull(router.getExceptionHandler());
|
||||||
|
|
||||||
AbstractRouter.ExceptionHandler custom = (ex, req, res) -> "Caught";
|
router.onException((ex, req, res) -> "Caught");
|
||||||
router.onException(custom);
|
|
||||||
|
|
||||||
assertEquals("Caught", router.getExceptionHandler().handle(new RuntimeException(), null, null));
|
assertEquals("Caught", router.getExceptionHandler().handle(new RuntimeException(), null, null));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import dev.relism.models.HeaderMap;
|
|||||||
import dev.relism.models.Request;
|
import dev.relism.models.Request;
|
||||||
import dev.relism.models.RequestHandler;
|
import dev.relism.models.RequestHandler;
|
||||||
import dev.relism.models.RequestLine;
|
import dev.relism.models.RequestLine;
|
||||||
import dev.relism.models.Response;
|
|
||||||
import dev.relism.models.SimpleHandler;
|
import dev.relism.models.SimpleHandler;
|
||||||
import dev.relism.routing.routers.fastpathrouter.FastPathViews;
|
import dev.relism.routing.routers.fastpathrouter.FastPathViews;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -40,7 +39,6 @@ class GlobalRouterTest {
|
|||||||
private Request mockRequest(String path) {
|
private Request mockRequest(String path) {
|
||||||
byte[] bytes = path.getBytes(StandardCharsets.UTF_8);
|
byte[] bytes = path.getBytes(StandardCharsets.UTF_8);
|
||||||
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length);
|
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length);
|
||||||
|
|
||||||
RequestLine line = new RequestLine(
|
RequestLine line = new RequestLine(
|
||||||
HttpMethod.GET, pathView, null,
|
HttpMethod.GET, pathView, null,
|
||||||
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
|
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
|
||||||
@@ -59,34 +57,25 @@ class GlobalRouterTest {
|
|||||||
RequestHandler hApiV1 = new SimpleHandler((req, res) -> "apiv1");
|
RequestHandler hApiV1 = new SimpleHandler((req, res) -> "apiv1");
|
||||||
|
|
||||||
global.mount("/api", new MockSubRouter(hApi));
|
global.mount("/api", new MockSubRouter(hApi));
|
||||||
global.mount("/api/v1", new MockSubRouter(hApiV1)); // longer prefix
|
global.mount("/api/v1", new MockSubRouter(hApiV1));
|
||||||
|
|
||||||
// Path matches /api/v1 -> Should pick hApiV1 because it's longer and sorted first
|
assertEquals(hApiV1, global.route(mockRequest("/api/v1/users")));
|
||||||
RequestHandler resolved = global.route(mockRequest("/api/v1/users"));
|
assertEquals(hApi, global.route(mockRequest("/api/v2/users")));
|
||||||
assertEquals(hApiV1, resolved);
|
|
||||||
|
|
||||||
// Path matches /api but not /api/v1
|
|
||||||
RequestHandler resolved2 = global.route(mockRequest("/api/v2/users"));
|
|
||||||
assertEquals(hApi, resolved2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void route_fallsBackToInternalRouter() throws Exception {
|
void route_fallsBackToInternalRouter() throws Exception {
|
||||||
GlobalRouter global = new GlobalRouter();
|
GlobalRouter global = new GlobalRouter();
|
||||||
RequestHandler internalHandler = new SimpleHandler((req, res) -> "internal");
|
global.doRegister(HttpMethod.GET, "/hello", (req, res) -> "internal", new Middleware[0]);
|
||||||
|
|
||||||
global.get("/hello", (req, res) -> "internal").with();
|
|
||||||
// We know it routes to internal. Let's send a request.
|
|
||||||
RequestHandler resolved = global.route(mockRequest("/hello"));
|
RequestHandler resolved = global.route(mockRequest("/hello"));
|
||||||
assertNotNull(resolved);
|
assertNotNull(resolved);
|
||||||
// It's the compiled FastPathRouter handler, let's verify it works
|
|
||||||
assertEquals("internal", resolved.handle(null, null));
|
assertEquals("internal", resolved.handle(null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void route_noMatch_returnsNotFoundHandler() {
|
void route_noMatch_returnsNotFoundHandler() {
|
||||||
GlobalRouter global = new GlobalRouter();
|
GlobalRouter global = new GlobalRouter();
|
||||||
// Nothing registered. Should return the global notFoundHandler.
|
|
||||||
RequestHandler resolved = global.route(mockRequest("/unknown"));
|
RequestHandler resolved = global.route(mockRequest("/unknown"));
|
||||||
assertEquals(global.getNotFoundHandler(), resolved);
|
assertEquals(global.getNotFoundHandler(), resolved);
|
||||||
}
|
}
|
||||||
@@ -102,10 +91,7 @@ class GlobalRouterTest {
|
|||||||
|
|
||||||
global.mount("/api", sub);
|
global.mount("/api", sub);
|
||||||
|
|
||||||
// Under sub-namespace
|
|
||||||
assertEquals(customSubHandler, global.resolveExceptionHandler(mockRequest("/api/fail")));
|
assertEquals(customSubHandler, global.resolveExceptionHandler(mockRequest("/api/fail")));
|
||||||
|
|
||||||
// Outside sub-namespace (global)
|
|
||||||
assertEquals(global.getExceptionHandler(), global.resolveExceptionHandler(mockRequest("/other")));
|
assertEquals(global.getExceptionHandler(), global.resolveExceptionHandler(mockRequest("/other")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-10
@@ -5,7 +5,7 @@ import dev.relism.models.HeaderMap;
|
|||||||
import dev.relism.models.Request;
|
import dev.relism.models.Request;
|
||||||
import dev.relism.models.RequestHandler;
|
import dev.relism.models.RequestHandler;
|
||||||
import dev.relism.models.RequestLine;
|
import dev.relism.models.RequestLine;
|
||||||
import dev.relism.models.SimpleHandler;
|
import dev.relism.routing.Middleware;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -14,12 +14,13 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
|
|
||||||
class FastPathRouterImplTest {
|
class FastPathRouterImplTest {
|
||||||
|
|
||||||
|
private static final Middleware[] NO_MIDDLEWARE = new Middleware[0];
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
private Request mockRequest(HttpMethod method, String path) {
|
private Request mockRequest(HttpMethod method, String path) {
|
||||||
byte[] bytes = path.getBytes(StandardCharsets.UTF_8);
|
byte[] bytes = path.getBytes(StandardCharsets.UTF_8);
|
||||||
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length);
|
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length);
|
||||||
|
|
||||||
RequestLine line = new RequestLine(
|
RequestLine line = new RequestLine(
|
||||||
method, pathView, null,
|
method, pathView, null,
|
||||||
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
|
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
|
||||||
@@ -34,8 +35,8 @@ class FastPathRouterImplTest {
|
|||||||
void route_lazyCompilationAndMatch() throws Exception {
|
void route_lazyCompilationAndMatch() throws Exception {
|
||||||
FastPathRouterImpl router = new FastPathRouterImpl();
|
FastPathRouterImpl router = new FastPathRouterImpl();
|
||||||
|
|
||||||
router.get("/a", (req, res) -> "A").with();
|
router.doRegister(HttpMethod.GET, "/a", (req, res) -> "A", NO_MIDDLEWARE);
|
||||||
router.post("/b", (req, res) -> "B").with();
|
router.doRegister(HttpMethod.POST, "/b", (req, res) -> "B", NO_MIDDLEWARE);
|
||||||
|
|
||||||
RequestHandler res1 = router.route(mockRequest(HttpMethod.GET, "/a"));
|
RequestHandler res1 = router.route(mockRequest(HttpMethod.GET, "/a"));
|
||||||
assertNotNull(res1);
|
assertNotNull(res1);
|
||||||
@@ -49,26 +50,23 @@ class FastPathRouterImplTest {
|
|||||||
@Test
|
@Test
|
||||||
void route_noMatch_returnsNull() {
|
void route_noMatch_returnsNull() {
|
||||||
FastPathRouterImpl router = new FastPathRouterImpl();
|
FastPathRouterImpl router = new FastPathRouterImpl();
|
||||||
router.get("/a", (req, res) -> "A").with();
|
router.doRegister(HttpMethod.GET, "/a", (req, res) -> "A", NO_MIDDLEWARE);
|
||||||
|
|
||||||
assertNull(router.route(mockRequest(HttpMethod.GET, "/b")));
|
assertNull(router.route(mockRequest(HttpMethod.GET, "/b")));
|
||||||
// Wrong method
|
|
||||||
assertNull(router.route(mockRequest(HttpMethod.POST, "/a")));
|
assertNull(router.route(mockRequest(HttpMethod.POST, "/a")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void route_extractsPathParams() throws Exception {
|
void route_extractsPathParams() throws Exception {
|
||||||
FastPathRouterImpl router = new FastPathRouterImpl();
|
FastPathRouterImpl router = new FastPathRouterImpl();
|
||||||
|
router.doRegister(HttpMethod.GET, "/users/{id}/items/{itemId}",
|
||||||
router.get("/users/{id}/items/{itemId}", (req, res) -> "Extract").with();
|
(req, res) -> "Extract", NO_MIDDLEWARE);
|
||||||
|
|
||||||
Request request = mockRequest(HttpMethod.GET, "/users/123/items/456");
|
Request request = mockRequest(HttpMethod.GET, "/users/123/items/456");
|
||||||
RequestHandler handler = router.route(request);
|
RequestHandler handler = router.route(request);
|
||||||
|
|
||||||
assertNotNull(handler);
|
assertNotNull(handler);
|
||||||
assertEquals("Extract", handler.handle(request, null));
|
assertEquals("Extract", handler.handle(request, null));
|
||||||
|
|
||||||
// Verify path params were injected
|
|
||||||
assertNotNull(request.getPathParams());
|
assertNotNull(request.getPathParams());
|
||||||
assertEquals("123", request.param("id"));
|
assertEquals("123", request.param("id"));
|
||||||
assertEquals("456", request.param("itemId"));
|
assertEquals("456", request.param("itemId"));
|
||||||
|
|||||||
@@ -53,10 +53,15 @@
|
|||||||
<artifactId>flash-ext-oidc</artifactId>
|
<artifactId>flash-ext-oidc</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-routeviewer</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>fpr-core</artifactId>
|
<artifactId>fpr-core</artifactId>
|
||||||
<version>1.1.0</version>
|
<version>1.1.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
|
|||||||
Reference in New Issue
Block a user