preparing for another refactoring...

This commit is contained in:
Relism
2026-03-29 23:16:41 +02:00
parent 2edd68b0aa
commit b5d4481502
69 changed files with 4329 additions and 1076 deletions
+4
View File
@@ -7,12 +7,16 @@
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-api/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-api/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java" 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-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/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/flash-ext-routeviewer/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/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" />
+73 -14
View File
@@ -4,31 +4,75 @@
<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="pre-major refactoring + ext api."> <list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="preparing for a conceptual refactoring...">
<change beforePath="$PROJECT_DIR$/.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/.gitignore" afterDir="false" /> <change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/Json.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/docs/README.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/docs/annotation.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/docs/guard.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/docs/http-headers.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/docs/key-resolvers.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/docs/strategies.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/pom.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Bucket.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/BucketStore.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Guard.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/KeyResolver.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/Limit.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimitConfig.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimitStrategy.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterConfig.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/LimiterExtension.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/RateLimitStrategy.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/FixedWindowStrategy.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/TokenBucketStrategy.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/routeviewer-ui/pnpm-lock.yaml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/pom.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Renderer.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/Template.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ThymeleafEngine.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/View.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewEngine.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewEngineType.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java/dev/relism/ext/view/ViewExtension.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/exceptions/InitializationException.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashContext.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteDefinition.java" 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/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$/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-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-jackson/src/main/java/dev/relism/ext/jackson/JacksonHandler.java" beforeDir="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/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-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/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/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/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityContributor.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityContributor.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityRegistry.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiSecurityRegistry.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/routeviewer-ui/src/layout.js" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/routeviewer-ui/src/layout.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/routeviewer-ui/src/nodes/HandlerNode.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/routeviewer-ui/src/nodes/HandlerNode.jsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/RouteViewerExtension.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/model/RouteRecord.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/ext/routeviewer/model/RouteRecord.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources/routeviewer/app.js" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources/routeviewer/app.js" 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-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/RequestParser.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/RequestParser.java" afterDir="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/ServerHandle.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/ServerHandle.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/exceptions/DuplicateNamespaceException.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/AnnotationProcessor.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/AnnotationProcessor.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/ExtensionContext.java" beforeDir="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/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/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/extension/FlashRegistrar.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashRegistrar.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashScope.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/FlashScope.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/PackageScanner.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/PackageScanner.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteListener.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/RouteListener.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Request.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/Request.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestHandler.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/models/RequestHandler.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/main/java/dev/relism/routing/GlobalRouter.java" beforeDir="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/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImpl.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/AbstractRouterTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/AbstractRouterTest.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/AbstractRouterTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/AbstractRouterTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/GlobalRouterTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/GlobalRouterTest.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/GlobalRouterTest.java" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java" afterDir="false" /> <change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/routing/routers/fastpathrouter/FastPathRouterImplTest.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/pom.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/pom.xml" afterDir="false" />
</list> </list>
@@ -44,7 +88,7 @@
</persistenceIdMap> </persistenceIdMap>
</component> </component>
<component name="EmbeddingIndexingInfo"> <component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="203" /> <option name="cachedIndexableFilesCount" value="226" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" /> <option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component> </component>
<component name="FileTemplateManagerImpl"> <component name="FileTemplateManagerImpl">
@@ -99,6 +143,8 @@
&quot;Maven.flash-bench [install].executor&quot;: &quot;Run&quot;, &quot;Maven.flash-bench [install].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-bench [package].executor&quot;: &quot;Run&quot;, &quot;Maven.flash-bench [package].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-bench [validate].executor&quot;: &quot;Run&quot;, &quot;Maven.flash-bench [validate].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-ext-limiter [install].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-ext-limiter [package].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [install].executor&quot;: &quot;Run&quot;, &quot;Maven.flash-parent [install].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [package].executor&quot;: &quot;Run&quot;, &quot;Maven.flash-parent [package].executor&quot;: &quot;Run&quot;,
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;, &quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
@@ -271,6 +317,10 @@
<workItem from="1774628513273" duration="86000" /> <workItem from="1774628513273" duration="86000" />
<workItem from="1774638461244" duration="5718000" /> <workItem from="1774638461244" duration="5718000" />
<workItem from="1774691772785" duration="3801000" /> <workItem from="1774691772785" duration="3801000" />
<workItem from="1774703412987" duration="25271000" />
<workItem from="1774777423667" duration="2127000" />
<workItem from="1774790933314" duration="10099000" />
<workItem from="1774814271256" duration="2412000" />
</task> </task>
<task id="LOCAL-00001" summary="Initial"> <task id="LOCAL-00001" summary="Initial">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -336,7 +386,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1774530802482</updated> <updated>1774530802482</updated>
</task> </task>
<option name="localTasksCounter" value="9" /> <task id="LOCAL-00009" summary="preparing for a conceptual refactoring...">
<option name="closed" value="true" />
<created>1774703474044</created>
<option name="number" value="00009" />
<option name="presentableId" value="LOCAL-00009" />
<option name="project" value="LOCAL" />
<updated>1774703474044</updated>
</task>
<option name="localTasksCounter" value="10" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -374,7 +432,8 @@
<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" />
<MESSAGE value="pre-major refactoring + ext api." /> <MESSAGE value="pre-major refactoring + ext api." />
<option name="LAST_COMMIT_MESSAGE" value="pre-major refactoring + ext api." /> <MESSAGE value="preparing for a conceptual refactoring..." />
<option name="LAST_COMMIT_MESSAGE" value="preparing for a conceptual refactoring..." />
</component> </component>
<component name="XSLT-Support.FileAssociations.UIState"> <component name="XSLT-Support.FileAssociations.UIState">
<expand /> <expand />
+1 -1
View File
@@ -124,7 +124,7 @@ app.mount("/api", scope -> {
## Extensions ## Extensions
Extensions are installed before route registration. Each extension receives the `FlashRegistrar` Extensions are installed before route registration. Each extension receives the `FlashRegistrar`
and `ExtensionContext` — it can register routes, expose services, and register annotation processors. and `FlashContext` — it can register routes, expose services, and register annotation processors.
```java ```java
FlashApp.create(8080) FlashApp.create(8080)
@@ -2,71 +2,76 @@ package dev.relism.ext.jackson;
import com.fasterxml.jackson.databind.ObjectMapper; 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.extension.FlashContext;
import dev.relism.extension.ExtensionContext;
import dev.relism.extension.FlashExtension; import dev.relism.extension.FlashExtension;
import dev.relism.extension.FlashRegistrar; import dev.relism.extension.FlashRegistrar;
import dev.relism.http.ContentType;
/** /**
* Registers Jackson into the extension layer. * Registers JSON support into the Flash extension layer.
* *
* <p>What this installs: * <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under
* <ul> * {@code Json.class}. Any handler or extension in the same scope can retrieve
* <li>Exposes the {@link ObjectMapper} in {@link ExtensionContext} — consumed by * it via {@code require(Json.class)} inside {@code onInit()}.
* {@code flash-ext-openapi} and any extension that needs JSON serialization.</li>
* <li>Sets a global exception handler that maps {@link HttpException} to a JSON
* error body and catches all other exceptions as 500.</li>
* <li>Injects the mapper into {@link JacksonHandler} so all subclasses gain
* {@code bodyAs} and {@code json} without constructor boilerplate.</li>
* </ul>
* *
* <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
*
* <h3>Usage — composition (preferred)</h3>
* <pre>{@code * <pre>{@code
* FlashApp.of(new HttpServer(config)) * // No mandatory base class. Works from any RequestHandler.
* .install(new JacksonExtension()); * public class MyHandler extends RequestHandler {
* private Json json;
* *
* // Custom mapper: * @Override protected void onInit() {
* json = require(Json.class);
* }
*
* public Object handle(Request req, Response res) throws Exception {
* MyDto dto = json.body(req, MyDto.class);
* return json.write(res, 201, dto);
* }
* }
* }</pre>
*
* <h3>Usage — convenience base class</h3>
* <pre>{@code
* // JacksonHandler remains available as a thin opt-in wrapper.
* public class MyHandler extends JacksonHandler {
* public Object handle(Request req, Response res) throws Exception {
* return json(res, service.findAll());
* }
* }
* }</pre>
*
* <h3>Custom mapper</h3>
* <pre>{@code
* ObjectMapper mapper = JsonMapper.builder() * ObjectMapper mapper = JsonMapper.builder()
* .addModule(new JavaTimeModule()) * .addModule(new JavaTimeModule())
* .build(); * .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
* .install(new JacksonExtension(mapper)); * .build();
*
* FlashApp.create(8080)
* .install(new JacksonExtension(mapper));
* }</pre> * }</pre>
*/ */
public class JacksonExtension implements FlashExtension { public class JacksonExtension implements FlashExtension {
private final ObjectMapper mapper; private final ObjectMapper mapper;
/** Installs with a default {@link JsonMapper} (no extra modules). */
public JacksonExtension() { public JacksonExtension() {
this(JsonMapper.builder().build()); this(JsonMapper.builder().build());
} }
/** Installs with a fully configured custom {@link ObjectMapper}. */
public JacksonExtension(ObjectMapper mapper) { public JacksonExtension(ObjectMapper mapper) {
this.mapper = mapper; this.mapper = mapper;
} }
@Override @Override
public void install(FlashRegistrar app, ExtensionContext ctx) { public void install(FlashRegistrar app, FlashContext ctx) {
ctx.provide(ObjectMapper.class, mapper); Json json = new Json(mapper);
JacksonHandler.mapper = mapper; ctx.provide(Json.class, json);
ctx.provide(ObjectMapper.class, mapper); // backward compat for extensions (OpenAPI, etc.)
app.onException((ex, req, res) -> {
if (ex instanceof HttpException e) {
res.setStatusCode(e.status());
res.setContentType(ContentType.JSON);
return "{\"error\":\"" + escapeJson(e.getMessage()) + "\"}";
}
res.setStatusCode(500);
res.setContentType(ContentType.JSON);
return "{\"error\":\"Internal Server Error\"}";
});
}
private static String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
} }
} }
@@ -1,77 +0,0 @@
package dev.relism.ext.jackson;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.exceptions.HttpException;
import dev.relism.http.ContentType;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import dev.relism.models.Response;
/**
* Base class for handlers that need JSON I/O via Jackson.
*
* <p>The {@link ObjectMapper} is injected by {@link JacksonExtension#install} once at
* startup — all subclasses share the same instance. If no {@code JacksonExtension} is
* installed the field remains {@code null} and the first call to {@link #bodyAs} or
* {@link #json} will throw an {@link IllegalStateException}.
*
* <pre>{@code
* @Route(method = HttpMethod.POST, path = "/api/blogs")
* public class CreateBlog extends JacksonHandler {
* public Object handle(Request req, Response res) throws Exception {
* CreateBlogRequest body = bodyAs(req, CreateBlogRequest.class);
* Blog created = service.create(body);
* res.setStatusCode(201);
* return json(res, created);
* }
* }
* }</pre>
*/
public abstract class JacksonHandler extends RequestHandler {
/**
* Shared mapper set by {@link JacksonExtension}. Package-visible so the extension
* can assign it; {@code volatile} ensures visibility across virtual threads.
*/
static volatile ObjectMapper mapper;
/**
* Deserializes the request body bytes into {@code type}.
* Wraps Jackson parse errors as {@link HttpException} 400.
*/
protected <T> T bodyAs(Request req, Class<T> type) throws Exception {
requireMapper();
try {
return mapper.readValue(req.body().bytes(), type);
} catch (JsonProcessingException e) {
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
}
}
/**
* Serializes {@code obj} to JSON, sets {@code Content-Type: application/json},
* and returns the JSON string as the response body.
*/
protected String json(Response res, Object obj) throws Exception {
requireMapper();
res.setContentType(ContentType.JSON);
return mapper.writeValueAsString(obj);
}
/**
* Like {@link #json} but serializes only fields visible under the given
* {@code view} class (see Jackson {@code @JsonView}).
*/
protected String jsonView(Response res, Object obj, Class<?> view) throws Exception {
requireMapper();
res.setContentType(ContentType.JSON);
return mapper.writerWithView(view).writeValueAsString(obj);
}
private static void requireMapper() {
if (mapper == null)
throw new IllegalStateException(
"JacksonExtension not installed: call FlashApp.install(new JacksonExtension()) first");
}
}
@@ -0,0 +1,126 @@
package dev.relism.ext.jackson;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.exceptions.HttpException;
import dev.relism.http.ContentType;
import dev.relism.models.Request;
import dev.relism.models.Response;
/**
* Thread-safe JSON toolbox. Single point of access for all JSON I/O operations
* within a Flash application.
*
* <p>Retrieve once at boot time via {@code require(Json.class)} inside
* {@code onInit()}, cache in a private field, and call on the hot path
* with zero lookup or allocation overhead:
*
* <pre>{@code
* @Route(method = HttpMethod.POST, path = "/api/items")
* public class CreateItemHandler extends RequestHandler {
*
* private Json json;
*
* @Override
* protected void onInit() {
* json = require(Json.class);
* }
*
* public Object handle(Request req, Response res) throws Exception {
* CreateItemRequest body = json.body(req, CreateItemRequest.class);
* return json.write(res, itemService.create(body));
* }
* }
* }</pre>
*
* <p>The underlying {@link ObjectMapper} is shared across all handlers in the same
* scope (one instance per app / per child scope). Jackson's {@code ObjectMapper}
* is fully thread-safe after configuration — no synchronization is needed.
*
* <p>Install via {@link JacksonExtension} before calling {@code scan()} or
* {@code register()}.
*/
public final class Json {
private final ObjectMapper mapper;
/** Package-private — constructed exclusively by {@link JacksonExtension}. */
Json(ObjectMapper mapper) {
this.mapper = mapper;
}
// ── Input ─────────────────────────────────────────────────────────────────
/**
* Deserializes the full request body into an instance of {@code type}.
*
* <p>Reads {@code req.body().bytes()} in one shot. For streaming bodies
* use {@link #bodyFrom(Request, Class)} instead.
*
* @throws HttpException 400 if the body cannot be parsed as {@code type}
*/
public <T> T body(Request req, Class<T> type) throws Exception {
try {
return mapper.readValue(req.body().bytes(), type);
} catch (JsonProcessingException e) {
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
}
}
/**
* Deserializes the request body via the raw {@link java.io.InputStream},
* avoiding the intermediate {@code byte[]} allocation. Prefer this for
* large bodies or when allocation budget is tight.
*
* @throws HttpException 400 on parse failure
*/
public <T> T bodyFrom(Request req, Class<T> type) throws Exception {
try {
return mapper.readValue(req.body().stream(), type);
} catch (JsonProcessingException e) {
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
}
}
// ── Output ────────────────────────────────────────────────────────────────
/**
* Serializes {@code obj} to a JSON string and sets
* {@code Content-Type: application/json} on the response.
*
* <p>The returned string is used as the response body by the Flash runtime.
*/
public String write(Response res, Object obj) throws Exception {
res.setContentType(ContentType.JSON);
return mapper.writeValueAsString(obj);
}
/**
* Like {@link #write(Response, Object)} but also sets an explicit HTTP status code.
*/
public String write(Response res, int status, Object obj) throws Exception {
res.status(status);
res.setContentType(ContentType.JSON);
return mapper.writeValueAsString(obj);
}
/**
* Like {@link #write} but applies a Jackson {@code @JsonView} filter,
* restricting serialization to fields visible under {@code view}.
*/
public String writeView(Response res, Object obj, Class<?> view) throws Exception {
res.setContentType(ContentType.JSON);
return mapper.writerWithView(view).writeValueAsString(obj);
}
// ── Escape hatch ──────────────────────────────────────────────────────────
/**
* Returns the underlying {@link ObjectMapper} for advanced operations
* (custom serialization, schema generation, etc.) not covered by the
* methods above.
*/
public ObjectMapper mapper() {
return mapper;
}
}
@@ -0,0 +1,65 @@
# flash-ext-limiter
Rate limiting for the Flash HTTP server. Zero-allocation hot-path, lock-free counters,
pluggable key resolvers, and two built-in algorithms.
## What it provides
| Component | Description |
|---|---|
| `@Limit` | Annotation for class-based handlers — processed once at boot |
| `Guard` | Programmatic middleware factory for lambda routes |
| `LimiterConfig` | Resolver registry — map string names to key-extraction lambdas |
| `FIXED_WINDOW` | Clock-aligned counter reset; minimal memory |
| `TOKEN_BUCKET` | Continuous refill; absorbs bursts smoothly |
## Dependency
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-limiter</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
```
## Quick start
```java
// Default install — only the built-in "ip" resolver available
FlashApp.create(8080)
.install(new LimiterExtension())
.scan("com.example.handlers");
```
```java
// With custom resolvers
LimiterConfig conf = new LimiterConfig()
.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
FlashApp.create(8080)
.install(new LimiterExtension(conf))
.scan("com.example.handlers");
```
## Installation order
Install `LimiterExtension` **before** authentication extensions. Rate-limit checks
then short-circuit over-limit requests before expensive token validation runs.
```java
app.install(new LimiterExtension(conf)) // ← first
.install(new OidcExtension(oidcConf)) // ← second
.scan("com.example");
```
## Docs
| File | Contents |
|---|---|
| [key-resolvers.md](key-resolvers.md) | Resolver registration, built-in defaults, custom logic |
| [annotation.md](annotation.md) | `@Limit` reference — all fields and examples |
| [guard.md](guard.md) | `Guard` for lambda routes — all overloads |
| [strategies.md](strategies.md) | `FIXED_WINDOW` vs `TOKEN_BUCKET` — algorithm reference |
| [http-headers.md](http-headers.md) | HTTP compliance — headers and 429 response |
@@ -0,0 +1,132 @@
# @Limit annotation
Applies a rate limit to a **class-based** `RequestHandler`. The annotation is read once
per handler class at boot by the `LimiterExtension` annotation processor — zero overhead
at request time.
## Declaration
```java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Limit {
String key() default "ip";
int requests();
long window();
TimeUnit windowUnit() default TimeUnit.SECONDS;
LimitStrategy strategy() default LimitStrategy.FIXED_WINDOW;
}
```
## Fields
| Field | Type | Default | Description |
|---|---|---|---|
| `key` | `String` | `"ip"` | Name of the key resolver registered in `LimiterConfig` |
| `requests` | `int` | — | Maximum requests allowed per window (required) |
| `window` | `long` | — | Window duration in `windowUnit` units (required) |
| `windowUnit` | `TimeUnit` | `SECONDS` | Time unit for `window` |
| `strategy` | `LimitStrategy` | `FIXED_WINDOW` | Rate-limit algorithm |
## Basic usage
### 100 requests per second per IP (default)
```java
@Route(method = HttpMethod.GET, path = "/api/search")
@Limit(requests = 100, window = 1)
public class SearchHandler extends RequestHandler {
public Object handle(Request req, Response res) {
return searchService.query(req.query("q"));
}
}
```
### 20 requests per minute per authenticated user
```java
@Route(method = HttpMethod.POST, path = "/api/report")
@Limit(key = "auth_user", requests = 20, window = 1, windowUnit = TimeUnit.MINUTES)
@Authenticated
public class ReportHandler extends RequestHandler {
public Object handle(Request req, Response res) { ... }
}
```
Order of annotation processors: register `LimiterExtension` before `OidcExtension`
so the rate-limit middleware wraps the outer layer of the chain and fires before auth.
### Token bucket — absorb bursts
```java
@Route(method = HttpMethod.POST, path = "/api/upload")
@Limit(
key = "api_key",
requests = 50,
window = 1,
windowUnit = TimeUnit.MINUTES,
strategy = LimitStrategy.TOKEN_BUCKET
)
public class UploadHandler extends RequestHandler { ... }
```
### Large window — 1000 requests per hour
```java
@Route(method = HttpMethod.GET, path = "/api/export")
@Limit(requests = 1000, window = 1, windowUnit = TimeUnit.HOURS)
public class ExportHandler extends RequestHandler { ... }
```
### Strict per-second limit on a public endpoint
```java
@Route(method = HttpMethod.GET, path = "/api/prices")
@Limit(requests = 10, window = 1, windowUnit = TimeUnit.SECONDS)
public class PriceHandler extends RequestHandler { ... }
```
## Combining @Limit with other annotations
`@Limit` composes naturally with `@Authenticated`, `@RolesAllowed`, and `@ApiOperation`.
Each annotation is processed by its own processor; Flash collects all middleware and
composes them in processor registration order.
```java
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
@Limit(key = "auth_user", requests = 5, window = 1, windowUnit = TimeUnit.MINUTES)
@RolesAllowed("admin")
@ApiOperation(summary = "Delete a user", tags = "admin")
public class DeleteUserHandler extends RequestHandler { ... }
```
Execution chain (outermost → handler):
`LimiterMiddleware → OidcRolesMiddleware → DeleteUserHandler`
## Fail-fast at boot
If `key` names a resolver not registered in `LimiterConfig`, the server refuses to start:
```
dev.relism.exceptions.InitializationException:
Rate-limit resolver "auth_user" is not registered.
Call LimiterConfig.registerResolver("auth_user", req -> ...) before install.
```
There is no silent fallback — a misconfigured rate limit is treated as a hard error.
## What happens on violation
```
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711750860
Retry-After: 1
Content-Type: text/plain
Too Many Requests
```
The handler body is never invoked. See [http-headers.md](http-headers.md) for the full
header reference.
@@ -0,0 +1,144 @@
# Guard — programmatic rate limiting for lambda routes
`Guard` is the rate-limit API for lambda (inline) route registrations. It produces
a `Middleware` that is composed once at route-wiring time — the resolver lambda is
captured directly into the closure, with no map lookup on the request hot-path.
## Obtaining Guard
`Guard` is provided in the `FlashContext` after `LimiterExtension` is installed:
```java
Guard guard = app.ctx().require(Guard.class);
```
Or inside another extension:
```java
public void install(FlashRegistrar app, FlashContext ctx) {
Guard guard = ctx.require(Guard.class);
// ...
}
```
## API
```java
// Fixed window (default strategy)
Middleware limit(String resolverKey, int requests, long window, TimeUnit unit)
// Explicit strategy
Middleware limit(String resolverKey, int requests, long window, TimeUnit unit, LimitStrategy strategy)
```
Both overloads:
- Resolve the named key lambda **once** at call time (fail-fast if unknown).
- Return a stateless `Middleware` whose closure captures the lambda and `LimitConfig` directly.
- Share the `BucketStore` with all other rules registered through this `LimiterExtension` instance.
## Examples
### Simple per-IP limit on a lambda route
```java
Guard guard = app.ctx().require(Guard.class);
app.get("/api/search", (req, res) -> searchService.query(req.query("q")))
.with(guard.limit("ip", 100, 1, TimeUnit.SECONDS));
```
### Per authenticated user — token bucket
```java
app.post("/api/export", (req, res) -> exportService.run(req))
.with(guard.limit("auth_user", 10, 1, TimeUnit.MINUTES, LimitStrategy.TOKEN_BUCKET));
```
### Chaining with other middleware
`Guard.limit(...)` returns a plain `Middleware`, so it composes with `Middleware.of()`
and `.andThen()` exactly like any other middleware:
```java
Middleware secured = Middleware.of(
guard.limit("ip", 200, 1, TimeUnit.SECONDS), // ← outermost: runs first
oidc.protect()
);
app.get("/dashboard", handler).with(secured);
```
Or with `.andThen()` for two middlewares:
```java
app.get("/dashboard", handler)
.with(guard.limit("ip", 200, 1, TimeUnit.SECONDS).andThen(oidc.protect()));
```
### Different limits on the same path by method
```java
// Read: 500/s; Write: 20/s
app.get("/api/items", readHandler) .with(guard.limit("ip", 500, 1, TimeUnit.SECONDS));
app.post("/api/items", writeHandler).with(guard.limit("ip", 20, 1, TimeUnit.SECONDS));
```
Each `.with(guard.limit(...))` call creates an independent bucket store key namespace —
GET and POST requests to `/api/items` share the same IP bucket only if you share the same
`Middleware` instance. Using two `guard.limit(...)` calls creates **two independent buckets**.
### Reusing a middleware instance across routes
To share a single bucket pool across multiple routes (treating them as one combined limit):
```java
Middleware sharedIpLimit = guard.limit("ip", 1000, 1, TimeUnit.MINUTES);
app.get("/api/items", handler1).with(sharedIpLimit);
app.get("/api/items/{id}", handler2).with(sharedIpLimit);
app.post("/api/items", handler3).with(sharedIpLimit);
```
All three routes now draw from the same per-IP bucket — 1000 combined requests per minute.
### Inside an extension
```java
public class MyApiExtension implements FlashExtension {
public void install(FlashRegistrar app, FlashContext ctx) {
Guard guard = ctx.require(Guard.class); // LimiterExtension must be installed first
Middleware ipLimit = guard.limit("ip", 60, 1, TimeUnit.SECONDS);
app.get("/api/status", statusHandler) .with(ipLimit);
app.get("/api/metrics", metricsHandler).with(ipLimit);
}
}
```
### Large window
```java
app.get("/api/export", exportHandler)
.with(guard.limit("api_key", 50, 24, TimeUnit.HOURS));
```
## Fail-fast
If the resolver name is not registered, `guard.limit(...)` throws immediately
(at wiring time, not at request time):
```
InitializationException: Rate-limit resolver "auth_user" is not registered.
```
## Comparison: Guard vs @Limit
| | `@Limit` | `Guard.limit(...)` |
|---|---|---|
| Route style | Class-based `RequestHandler` | Lambda `(req, res) -> ...` |
| Configuration | Annotation fields | Method arguments |
| Where resolved | `AnnotationProcessor` at `scan()` | `guard.limit(...)` call at wiring |
| Hot-path overhead | Zero | Zero |
| Fail-fast | Yes | Yes |
| Composable with `Middleware.of()` | Via annotation processor order | Yes, directly |
@@ -0,0 +1,122 @@
# HTTP headers and 429 response
The extension injects standard rate-limit headers on **every** request — both allowed
and rejected. Clients can use these headers to implement back-off logic without waiting
for a 429.
## Response headers
| Header | Type | Description |
|---|---|---|
| `X-RateLimit-Limit` | integer | Maximum requests allowed in the current window |
| `X-RateLimit-Remaining` | integer | Requests remaining in the current window (≥ 0) |
| `X-RateLimit-Reset` | Unix timestamp (s) | When the quota resets or the next token arrives |
| `Retry-After` | seconds | **Only on 429** — how long to wait before retrying (≥ 1) |
### Example — allowed request
```
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 73
X-RateLimit-Reset: 1711750860
Content-Type: application/json
```
### Example — rejected request (429)
```
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711750860
Retry-After: 1
Content-Type: text/plain
Too Many Requests
```
## Header semantics by strategy
### FIXED_WINDOW
| Header | Value |
|---|---|
| `X-RateLimit-Reset` | Unix timestamp of the **next window start** (aligned to clock) |
| `Retry-After` | Seconds until `X-RateLimit-Reset` (minimum 1) |
At a 1-second window boundary `Retry-After` will typically be `1`.
### TOKEN_BUCKET
| Header | Value |
|---|---|
| `X-RateLimit-Remaining` | Current token count (may increase between requests due to refill) |
| `X-RateLimit-Reset` | Estimated Unix timestamp when the **next token arrives** |
| `Retry-After` | Milliseconds-precise estimate converted to seconds (minimum 1) |
Because the token bucket refills continuously, `X-RateLimit-Reset` is a near-future
timestamp rather than an aligned window boundary.
## Retry-After precision
`Retry-After` is computed as:
```
retryAfter = max(1, X-RateLimit-Reset - currentTimeSeconds)
```
The minimum value is always `1` second — RFC 7231 discourages `Retry-After: 0` as it
encourages instant retry loops.
## Client-side back-off example (Java)
```java
HttpResponse<String> res = client.send(request, BodyHandlers.ofString());
if (res.statusCode() == 429) {
String retryAfter = res.headers().firstValue("Retry-After").orElse("1");
long waitMs = Long.parseLong(retryAfter) * 1000L;
Thread.sleep(waitMs);
// retry...
}
```
## Client-side back-off example (JavaScript fetch)
```js
const res = await fetch('/api/search?q=flash');
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '1', 10);
await new Promise(r => setTimeout(r, retryAfter * 1000));
// retry...
}
```
## Monitoring / alerting
`X-RateLimit-Remaining` can be scraped by a metrics agent to track approaching limits
before they hit 429:
- `remaining / limit < 0.1` → warning (less than 10% quota left)
- `status == 429` → rate-limit violation counter increment
If `flash-ext-limiter` is used together with a future metrics extension, the 429 rate
per resolver key is a natural signal for abuse detection or auto-scaling.
## Header injection timing
Headers are injected **before** calling `next.handle(req, res)` on allowed requests,
and **instead of** calling it on rejected requests. This means:
- Handlers cannot accidentally overwrite `X-RateLimit-*` headers (they are set first,
but handlers that call `res.header(...)` with the same name will add a second value —
avoid this by not setting these headers manually).
- On 429, the handler body is never executed — no side effects occur.
## Integration with Swagger UI (flash-ext-openapi)
Rate-limit headers are not currently injected into the OpenAPI spec. If you want to
document them, add them manually via `@ApiOperation` on the handler class using the
response headers section of the OpenAPI spec.
@@ -0,0 +1,143 @@
# Key Resolvers
A **key resolver** is a lambda `Request → String` that extracts the partition key used
to identify who a rate limit applies to. Each unique key value gets its own independent
bucket — so `"ip"` limits per client address, `"auth_user"` limits per logged-in user, etc.
## Built-in resolver: `"ip"`
Always present. Cannot be removed; can be overridden with `registerResolver("ip", ...)`.
Resolution order:
1. `X-Forwarded-For` header — first address in the comma-separated list (client behind proxy)
2. `X-Real-IP` header — single forwarded IP (nginx `proxy_set_header X-Real-IP`)
3. `req.remoteAddress().getAddress().getHostAddress()` — direct socket address, zero allocation
(the `InetSocketAddress` already exists from `ServerSocket.accept()`; only `getHostAddress()`
allocates a String, and only when the first two headers are absent)
4. `"unknown"` — only if `remoteAddress()` is null (test-constructed requests)
```java
// Override the built-in "ip" resolver to trust only the last hop in X-Forwarded-For
conf.registerResolver("ip", req -> {
String xff = req.header("X-Forwarded-For");
if (xff != null) {
String[] parts = xff.split(",");
return parts[parts.length - 1].strip(); // last = most recent proxy
}
return req.header("X-Real-IP") != null ? req.header("X-Real-IP").strip() : "unknown";
});
```
## Registering custom resolvers
```java
LimiterConfig conf = new LimiterConfig();
```
### By authenticated user (OIDC / ClaimsHolder)
```java
conf.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
```
Requests from unauthenticated users share the `"anonymous"` bucket. If you want
unauthenticated requests to be unlimited, pair this resolver with `@Limit` only on
handlers that are already protected by `@Authenticated`.
### By API key header
```java
conf.registerResolver("api_key", req -> {
String key = req.header("X-Api-Key");
return key != null ? key : "none";
});
```
### By tenant (multi-tenant SaaS)
```java
conf.registerResolver("tenant", req -> {
// Extract from subdomain: acme.api.example.com → "acme"
String host = req.header("Host");
if (host == null) return "unknown";
int dot = host.indexOf('.');
return dot > 0 ? host.substring(0, dot) : host;
});
```
### By IP + path (per-endpoint per-IP)
Combines two dimensions into a single key string:
```java
conf.registerResolver("ip_path", req -> {
String ip = req.header("X-Forwarded-For");
if (ip == null) ip = "unknown";
int comma = ip.indexOf(',');
if (comma > 0) ip = ip.substring(0, comma).strip();
return ip + "|" + req.path();
});
```
### Composite: role-based bucket size
One resolver, two different `@Limit` thresholds on two handler classes. The resolver
returns the same key for the same user regardless of endpoint; the limit is set per handler.
```java
conf.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anon");
```
```java
@Limit(key = "auth_user", requests = 1000, window = 1) // privileged endpoint
public class AdminReportHandler extends RequestHandler { ... }
@Limit(key = "auth_user", requests = 20, window = 1) // public endpoint
public class PublicSearchHandler extends RequestHandler { ... }
```
The two handlers maintain **independent buckets** for the same user — each `@Limit`
annotation gets its own `BucketStore`.
## Resolver contract
```java
@FunctionalInterface
public interface KeyResolver {
String resolve(Request req); // must never return null; return "unknown" as fallback
}
```
- Must not return `null` — a null key will throw `NullPointerException` inside `ConcurrentHashMap`.
- Must be **thread-safe** — called concurrently from virtual threads.
- Should be **fast** — it runs on every request for every rate-limited route.
- No state should be mutated — treat `Request` as read-only.
## Fail-fast validation
If a `@Limit` annotation or `guard.limit(...)` call references a resolver name that was never
registered, the server **refuses to start** with `InitializationException`:
```
InitializationException: Rate-limit resolver "auth_user" is not registered.
Call LimiterConfig.registerResolver("auth_user", req -> ...) before install.
```
This check happens at boot time (annotation processor / Guard wiring), not at request time.
## Registration API
```java
LimiterConfig conf = new LimiterConfig()
.registerResolver("auth_user", req -> ...)
.registerResolver("tenant", req -> ...)
.registerResolver("api_key", req -> ...);
app.install(new LimiterExtension(conf));
```
`registerResolver` returns `this` for fluent chaining. Calling it with an existing name
**replaces** the previous resolver — this is how you override the built-in `"ip"` resolver.
@@ -0,0 +1,178 @@
# Rate-limit strategies
Two algorithms are built in. Both are lock-free (CAS-only), operate on pre-allocated
`Bucket` state, and write results into a caller-supplied `long[2]` — zero per-request allocation.
## FIXED_WINDOW
```java
@Limit(strategy = LimitStrategy.FIXED_WINDOW, ...) // default, can be omitted
guard.limit("ip", 100, 1, TimeUnit.SECONDS) // default
```
### How it works
The request counter resets to zero at each clock-aligned window boundary.
```
window 1 window 2 window 3
|────────────────|────────────────|────────────────|
cnt: 0 1 2 … N cnt: 0 1 2 … N cnt: 0 1 2 … N
```
With `requests = 100, window = 1s`:
- Requests 1100 in a given second → allowed
- Request 101+ in that second → 429, allowed again at second +1
### Implementation
All state is packed into a single `AtomicLong` (`Bucket.slot0`):
```
high 32 bits = reduced epoch = (currentTimeMs / windowMs) & 0xFFFFFFFF
low 32 bits = request count in the current window
```
One CAS operation per request. At a window boundary the same CAS atomically resets the
counter to 1. No locks, no additional fields.
### Burst behaviour
Because the window is fixed to the clock, a burst can occur at the boundary:
up to `N` requests at the end of window 1 followed immediately by `N` requests at the
start of window 2 → `2N` requests in a short interval.
```
window 1 │ window 2
────────────┼────────────
99 100 101 │ 1 2 3 4
↑ reset: 101 → 429, then 1 is allowed
```
If burst tolerance is unacceptable, use `TOKEN_BUCKET`.
### When to use
- Simple API rate limiting where occasional boundary bursts are acceptable.
- Scenarios where a hard "N requests per clock second/minute" guarantee matters.
- When you want minimal per-bucket memory (one `AtomicLong`, `Bucket.slot1` unused).
---
## TOKEN_BUCKET
```java
@Limit(strategy = LimitStrategy.TOKEN_BUCKET, ...)
guard.limit("ip", 100, 1, TimeUnit.SECONDS, LimitStrategy.TOKEN_BUCKET)
```
### How it works
The bucket holds up to `requests` tokens and refills at a continuous rate of
`requests / window` tokens per millisecond. Each request consumes one token.
A client that was idle accumulates tokens and can fire a burst, but sustained
excess traffic drains the bucket and triggers 429s.
```
tokens
N ─┐ ┌──── refill slope ────┐
│ │ │
0 └───────────┘ ←─ burst consumed ──→│
burst here 429s during drain recovery
```
### Refill rate
`refillPerMs = (requests × 1000) / windowMs` (integer, minimum 1)
For `requests = 100, window = 1s`:
- Refill rate: 100 tokens/s = 1 token/10 ms
- Max capacity: 100 tokens
- A client idle for 500 ms accumulates 50 tokens and can fire 50 requests instantly.
### Implementation
- `Bucket.slot0` — current tokens × 1000 (fixed-point, avoids floating-point math)
- `Bucket.slot1` — last-refill timestamp in ms (0 = uninitialised → bucket starts full)
One CAS loop on `slot0` per request; `slot1` updated best-effort after CAS success.
The bounded inaccuracy from the non-atomic dual update is at most a few nanoseconds —
negligible and self-correcting for rate limiting.
### Bucket starts full
On the very first request, `slot1 == 0`. The strategy treats this as "one full window
elapsed" → `currentTokens = max`. The bucket starts at capacity; no warm-up needed.
### When to use
- APIs where clients legitimately batch requests (analytics, bulk imports).
- Endpoints where smooth throughput matters more than hard per-second guarantees.
- Any scenario where `FIXED_WINDOW` boundary bursts would be problematic.
---
## Comparison
| | `FIXED_WINDOW` | `TOKEN_BUCKET` |
|---|---|---|
| Algorithm | Aligned counter reset | Continuous token refill |
| Burst handling | Allows 2× limit at boundaries | Absorbs bursts up to bucket capacity |
| Memory per bucket | 1 × `AtomicLong` used | 2 × `AtomicLong` used |
| Clock alignment | Yes (predictable resets) | No (smooth) |
| Typical use case | Simple request quotas | APIs with legitimate burst patterns |
| CAS operations per request | 1 (usually) | 1 (usually) |
Both strategies use the same `Bucket` type. Both are lock-free and allocation-free after
the bucket is first created.
---
## Adding a custom strategy
Implement `RateLimitStrategy` and wrap it in a `LimitStrategy` enum constant:
```java
// 1. Implement the strategy
public final class SlidingWindowStrategy implements RateLimitStrategy {
@Override
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
// ... lock-free implementation using bucket.slot0 / slot1
return allowed;
}
}
// 2. Add to the enum
public enum LimitStrategy {
FIXED_WINDOW { ... },
TOKEN_BUCKET { ... },
SLIDING_WINDOW {
@Override
public RateLimitStrategy create() { return new SlidingWindowStrategy(); }
};
public abstract RateLimitStrategy create();
}
```
The new strategy is immediately available to `@Limit(strategy = LimitStrategy.SLIDING_WINDOW)`
and `guard.limit("ip", 100, 1, SECONDS, LimitStrategy.SLIDING_WINDOW)`.
### Strategy contract
```java
public interface RateLimitStrategy {
/**
* @param bucket pre-allocated per-key state (never null)
* @param cfg immutable rule config (limit, windowMs)
* @param out out[0] = remaining, out[1] = reset epoch-seconds
* @return true = allowed, false = rejected (429)
*/
boolean check(Bucket bucket, LimitConfig cfg, long[] out);
}
```
Requirements for custom implementations:
- **Lock-free** — use `AtomicLong.compareAndSet`; no `synchronized` or `ReentrantLock`.
- **Stateless** — all mutable state must live in `Bucket.slot0` / `Bucket.slot1`.
- **No allocation** — `out[]` is the only output channel; do not create objects on the hot path.
- **Thread-safe** — called concurrently from many virtual threads.
@@ -0,0 +1,30 @@
<?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-limiter</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>
</project>
@@ -0,0 +1,22 @@
package dev.relism.ext.limiter;
import java.util.concurrent.atomic.AtomicLong;
/**
* Pre-allocated per-key rate limit state. Holds two {@link AtomicLong} slots whose
* semantics are strategy-specific:
*
* <ul>
* <li><b>FIXED_WINDOW</b>: {@code slot0} = packed {@code (epoch << 32 | count)},
* {@code slot1} unused.</li>
* <li><b>TOKEN_BUCKET</b>: {@code slot0} = tokens × 1000 (scaled),
* {@code slot1} = last-refill timestamp (ms since epoch).</li>
* </ul>
*
* <p>Buckets are created once per unique key (via {@link BucketStore}) and reused
* for the lifetime of the server — zero allocation on the warm path.
*/
public final class Bucket {
public final AtomicLong slot0 = new AtomicLong(0L);
public final AtomicLong slot1 = new AtomicLong(0L);
}
@@ -0,0 +1,28 @@
package dev.relism.ext.limiter;
import java.util.concurrent.ConcurrentHashMap;
/**
* Thread-safe store of pre-allocated {@link Bucket} instances keyed by partition key.
*
* <p>On the warm path (key already seen), {@link #get} performs a single
* {@link ConcurrentHashMap} lookup — no allocation. On the cold path (new key),
* {@code computeIfAbsent} allocates exactly one {@link Bucket} and inserts it.
*
* <p>Buckets accumulate indefinitely; for workloads with unbounded unique keys
* (e.g. one-shot crawlers), consider periodic store replacement or a bounded
* LRU map implementation.
*/
public final class BucketStore {
private final ConcurrentHashMap<String, Bucket> map = new ConcurrentHashMap<>();
/**
* Returns the bucket for {@code key}, creating one if absent.
* Two threads racing on the same new key are guaranteed to receive the same bucket instance.
*/
public Bucket get(String key) {
Bucket b = map.get(key);
return b != null ? b : map.computeIfAbsent(key, k -> new Bucket());
}
}
@@ -0,0 +1,69 @@
package dev.relism.ext.limiter;
import dev.relism.routing.Middleware;
import java.util.concurrent.TimeUnit;
/**
* Manual rate-limit guard for lambda routes.
*
* <p>Available via {@link dev.relism.extension.FlashContext}:
* <pre>{@code
* Guard guard = ctx.require(Guard.class);
* }</pre>
*
* <p>{@link #limit} creates a {@link Middleware} that is composed once at route registration
* time — the resolver lambda is captured directly from the registry (no runtime map lookup):
* <pre>{@code
* // 50 req/s per IP — fixed window (default)
* app.get("/api/search", handler)
* .with(guard.limit("ip", 50, 1, TimeUnit.SECONDS));
*
* // 10 req/min per authenticated user — token bucket
* app.post("/api/export", handler)
* .with(guard.limit("auth_user", 10, 1, TimeUnit.MINUTES, LimitStrategy.TOKEN_BUCKET));
* }</pre>
*
* <p>The resolver name is looked up once here (at {@code .with(guard.limit(...))} call time,
* i.e. during app wiring, not on each request). If the name is not registered,
* {@link dev.relism.exceptions.InitializationException} is thrown immediately.
*/
public final class Guard {
private final LimiterConfig config;
private final BucketStore store;
Guard(LimiterConfig config, BucketStore store) {
this.config = config;
this.store = store;
}
/**
* Returns a {@link Middleware} that enforces the given rate limit using
* {@link LimitStrategy#FIXED_WINDOW}.
*
* @param resolverKey name registered via {@link LimiterConfig#registerResolver}
* @param requests maximum requests allowed per window
* @param window window duration in {@code unit}
* @param unit time unit for {@code window}
*/
public Middleware limit(String resolverKey, int requests, long window, TimeUnit unit) {
return limit(resolverKey, requests, window, unit, LimitStrategy.FIXED_WINDOW);
}
/**
* Returns a {@link Middleware} that enforces the given rate limit with the specified strategy.
*
* @param resolverKey name registered via {@link LimiterConfig#registerResolver}
* @param requests maximum requests allowed per window
* @param window window duration in {@code unit}
* @param unit time unit for {@code window}
* @param strategy rate-limit algorithm
*/
public Middleware limit(String resolverKey, int requests, long window, TimeUnit unit, LimitStrategy strategy) {
// Fail-fast: resolve the lambda at wiring time, not at request time.
KeyResolver resolver = config.requireResolver(resolverKey);
LimitConfig cfg = new LimitConfig(requests, unit.toMillis(window), strategy.create());
return LimiterExtension.buildMiddleware(resolver, cfg, store);
}
}
@@ -0,0 +1,20 @@
package dev.relism.ext.limiter;
import dev.relism.models.Request;
/**
* Extracts a partition key from an incoming request.
*
* <p>The resolved key identifies who the rate limit applies to — an IP address,
* an authenticated user ID, an API key, etc. Implementations are captured once
* at route registration time and called directly (no registry lookup) on every request.
*
* <pre>{@code
* conf.registerResolver("ip", req -> req.header("X-Forwarded-For"));
* conf.registerResolver("auth_user", req -> ClaimsHolder.user().sub());
* }</pre>
*/
@FunctionalInterface
public interface KeyResolver {
String resolve(Request req);
}
@@ -0,0 +1,45 @@
package dev.relism.ext.limiter;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
/**
* Applies a rate limit to a class-based {@link dev.relism.models.RequestHandler}.
*
* <p>The annotation is processed at boot time by the {@link LimiterExtension} annotation
* processor. If {@link #key()} names a resolver that was never registered,
* startup fails immediately with {@link dev.relism.exceptions.InitializationException}.
*
* <pre>{@code
* // 100 req/s per client IP — fixed window
* @Limit(requests = 100, window = 1)
* public class SearchHandler extends RequestHandler { ... }
*
* // 20 req/min per authenticated user — token bucket
* @Limit(key = "auth_user", requests = 20, window = 1, windowUnit = TimeUnit.MINUTES,
* strategy = LimitStrategy.TOKEN_BUCKET)
* public class ExpensiveHandler extends RequestHandler { ... }
* }</pre>
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Limit {
/** Name of the resolver registered via {@link LimiterConfig#registerResolver}. Default: {@code "ip"}. */
String key() default "ip";
/** Maximum number of requests allowed per {@link #window()}. */
int requests();
/** Window duration in {@link #windowUnit()} units. */
long window();
/** Unit for {@link #window()}. Default: {@link TimeUnit#SECONDS}. */
TimeUnit windowUnit() default TimeUnit.SECONDS;
/** Rate-limit algorithm. Default: {@link LimitStrategy#FIXED_WINDOW}. */
LimitStrategy strategy() default LimitStrategy.FIXED_WINDOW;
}
@@ -0,0 +1,11 @@
package dev.relism.ext.limiter;
/**
* Immutable configuration snapshot for a single rate-limit rule.
* Created once at boot time and captured directly in the middleware closure.
*
* @param limit Maximum allowed requests per window.
* @param windowMs Window duration in milliseconds.
* @param strategy Strategy instance bound to this rule (one per rule, not shared).
*/
public record LimitConfig(int limit, long windowMs, RateLimitStrategy strategy) {}
@@ -0,0 +1,45 @@
package dev.relism.ext.limiter;
import dev.relism.ext.limiter.strategy.FixedWindowStrategy;
import dev.relism.ext.limiter.strategy.TokenBucketStrategy;
/**
* Enumeration of built-in rate-limit algorithms. Each constant is a factory
* for its corresponding {@link RateLimitStrategy} implementation.
*
* <p>New algorithms can be added here without touching the rest of the extension.
* The enum value is referenced by {@link Limit#strategy()} so user code refers
* to the algorithm by name ({@code LimitStrategy.FIXED_WINDOW}) rather than
* instantiating strategy objects directly.
*
* <pre>{@code
* @Limit(key = "ip", requests = 100, window = 1, strategy = LimitStrategy.TOKEN_BUCKET)
* public class SearchHandler extends RequestHandler { ... }
* }</pre>
*/
public enum LimitStrategy {
/**
* Fixed-window counter: resets to zero at each clock-aligned window boundary.
* Simple, minimal memory, but allows up to 2× the limit in bursts that straddle
* two windows.
*/
FIXED_WINDOW {
@Override
public RateLimitStrategy create() { return new FixedWindowStrategy(); }
},
/**
* Token-bucket: tokens refill continuously. Smooth burst absorption — a client
* that was idle accumulates tokens and can fire a short burst, but sustained
* excess traffic is rejected. Preferred for API endpoints where occasional bursts
* are legitimate.
*/
TOKEN_BUCKET {
@Override
public RateLimitStrategy create() { return new TokenBucketStrategy(); }
};
/** Creates a fresh, stateless {@link RateLimitStrategy} instance for this algorithm. */
public abstract RateLimitStrategy create();
}
@@ -0,0 +1,82 @@
package dev.relism.ext.limiter;
import dev.relism.exceptions.InitializationException;
import java.net.InetSocketAddress;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Extension configuration: holds the named {@link KeyResolver} registry.
*
* <p>Resolvers are registered during the <em>config phase</em> (before {@code install}).
* After {@link LimiterExtension#install} is called, the registry is consulted once per
* route/handler at boot to capture the resolver lambda directly into the middleware closure.
* There is no map lookup on the request hot-path.
*
* <p>The built-in {@code "ip"} resolver is always present and extracts the client IP from
* {@code X-Forwarded-For} (first address) or {@code X-Real-IP}. Override it with
* {@code registerResolver("ip", ...)} if needed.
*
* <pre>{@code
* LimiterConfig conf = new LimiterConfig()
* .registerResolver("auth_user", req -> {
* // custom logic — e.g. extract sub from ClaimsHolder
* return ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous";
* });
*
* app.install(new LimiterExtension(conf));
* }</pre>
*/
public final class LimiterConfig {
private final Map<String, KeyResolver> resolvers = new LinkedHashMap<>();
public LimiterConfig() {
// Built-in mandatory "ip" resolver.
// Resolution order (standard reverse-proxy chain):
// 1. X-Forwarded-For — first address (client behind one or more proxies)
// 2. X-Real-IP — single forwarded IP (nginx proxy_set_header X-Real-IP)
// 3. Socket address — direct connection, no proxy headers (zero alloc: the
// InetSocketAddress already exists from accept(); only
// getHostAddress() allocates a String, and only when reached)
resolvers.put("ip", req -> {
String xff = req.header("X-Forwarded-For");
if (xff != null) {
int comma = xff.indexOf(',');
return comma > 0 ? xff.substring(0, comma).strip() : xff.strip();
}
String xri = req.header("X-Real-IP");
if (xri != null) return xri.strip();
InetSocketAddress addr = req.remoteAddress();
return addr != null ? addr.getAddress().getHostAddress() : "unknown";
});
}
/**
* Registers (or replaces) a named key resolver. Returns {@code this} for fluent chaining.
*
* @param name identifier referenced by {@link Limit#key()} and {@link Guard#limit}
* @param resolver lambda that extracts the partition key from a request
*/
public LimiterConfig registerResolver(String name, KeyResolver resolver) {
if (name == null || name.isBlank()) throw new IllegalArgumentException("Resolver name must not be blank");
if (resolver == null) throw new IllegalArgumentException("Resolver must not be null");
resolvers.put(name, resolver);
return this;
}
/**
* Returns the resolver for {@code name}.
*
* @throws InitializationException if no resolver with that name has been registered —
* checked at boot time so misconfigurations surface immediately.
*/
KeyResolver requireResolver(String name) {
KeyResolver r = resolvers.get(name);
if (r == null) throw new InitializationException(
"Rate-limit resolver \"" + name + "\" is not registered. " +
"Call LimiterConfig.registerResolver(\"" + name + "\", req -> ...) before install.");
return r;
}
}
@@ -0,0 +1,130 @@
package dev.relism.ext.limiter;
import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashExtension;
import dev.relism.extension.FlashRegistrar;
import dev.relism.http.HttpStatus;
import dev.relism.routing.Middleware;
import java.util.List;
/**
* Rate-limiting extension for Flash.
*
* <p>On {@link #install}:
* <ol>
* <li>Creates a single {@link BucketStore} shared by all rules in this extension instance.</li>
* <li>Provides a {@link Guard} in the {@link FlashContext} for manual use on lambda routes.</li>
* <li>Registers an {@link dev.relism.extension.AnnotationProcessor} for {@link Limit}:
* reads the annotation once per handler class at boot, resolves the key lambda
* from the registry <em>fail-fast</em>, then returns a pre-compiled middleware
* that captures the lambda and config directly — zero map lookups at request time.</li>
* </ol>
*
* <h3>Annotation-based (class handlers)</h3>
* <pre>{@code
* @Limit(requests = 100, window = 1) // 100 req/s per IP
* public class SearchHandler extends RequestHandler { ... }
*
* @Limit(key = "auth_user", requests = 20, window = 1,
* windowUnit = TimeUnit.MINUTES,
* strategy = LimitStrategy.TOKEN_BUCKET)
* public class ReportHandler extends RequestHandler { ... }
* }</pre>
*
* <h3>Lambda routes (via Guard)</h3>
* <pre>{@code
* LimiterConfig conf = new LimiterConfig()
* .registerResolver("auth_user", req -> ClaimsHolder.user().sub());
*
* app.install(new LimiterExtension(conf));
*
* Guard guard = app.ctx().require(Guard.class);
* app.get("/api/search", handler).with(guard.limit("ip", 50, 1, TimeUnit.SECONDS));
* }</pre>
*
* <h3>Installation order matters</h3>
* Install {@code LimiterExtension} <em>before</em> authentication extensions so that
* rate-limit checks short-circuit before expensive token validation on over-limit requests.
*/
public final class LimiterExtension implements FlashExtension {
private final LimiterConfig config;
/** Installs with default config (only the built-in {@code "ip"} resolver). */
public LimiterExtension() {
this(new LimiterConfig());
}
/** Installs with a custom {@link LimiterConfig} (custom resolvers, etc.). */
public LimiterExtension(LimiterConfig config) {
this.config = config;
}
@Override
public void install(FlashRegistrar app, FlashContext ctx) {
BucketStore store = new BucketStore();
Guard guard = new Guard(config, store);
ctx.provide(Guard.class, guard);
ctx.provide(LimiterConfig.class, config);
// Annotation processor: runs once per class-based handler at boot.
ctx.addAnnotationProcessor(handlerClass -> {
Limit ann = handlerClass.getAnnotation(Limit.class);
if (ann == null) return List.of();
// Fail-fast: if the key is unknown the server refuses to start.
KeyResolver resolver = config.requireResolver(ann.key());
LimitConfig cfg = new LimitConfig(
ann.requests(),
ann.windowUnit().toMillis(ann.window()),
ann.strategy().create()
);
return List.of(buildMiddleware(resolver, cfg, store));
});
}
// ── Package-private helper — shared with Guard ────────────────────────────
/**
* Builds the rate-limit {@link Middleware} from an already-resolved resolver lambda.
*
* <p>Hot-path design:
* <ul>
* <li>{@code resolver} is captured directly in the closure — no registry lookup per request.</li>
* <li>{@code resultBuf} is a per-{@link Middleware}-instance ThreadLocal {@code long[2]}.
* Allocated once per thread, reused forever — zero per-request allocation.</li>
* <li>Header values ({@code String.valueOf(...)}) are the only unavoidable allocations;
* they are tiny and bounded.</li>
* </ul>
*/
static Middleware buildMiddleware(KeyResolver resolver, LimitConfig cfg, BucketStore store) {
// One result buffer per thread, per middleware instance.
// ThreadLocal is captured in the closure at boot time — not re-created per request.
ThreadLocal<long[]> resultBuf = ThreadLocal.withInitial(() -> new long[2]);
return next -> (req, res) -> {
String key = resolver.resolve(req);
Bucket bucket = store.get(key);
long[] out = resultBuf.get();
boolean allowed = cfg.strategy().check(bucket, cfg, out);
// Always inject rate-limit headers — useful even on allowed requests.
res.header("X-RateLimit-Limit", String.valueOf(cfg.limit()));
res.header("X-RateLimit-Remaining", String.valueOf(out[0]));
res.header("X-RateLimit-Reset", String.valueOf(out[1]));
if (!allowed) {
long retryAfter = Math.max(1L, out[1] - System.currentTimeMillis() / 1000L);
res.status(HttpStatus.TOO_MANY_REQUESTS)
.header("Retry-After", String.valueOf(retryAfter));
return "Too Many Requests";
}
return next.handle(req, res);
};
}
}
@@ -0,0 +1,34 @@
package dev.relism.ext.limiter;
/**
* Contract for a rate-limit algorithm. Implementations must be:
* <ul>
* <li><b>Lock-free</b> — rely only on {@link java.util.concurrent.atomic.AtomicLong} CAS operations.</li>
* <li><b>Stateless</b> — all mutable state lives in the {@link Bucket}; the strategy itself
* holds no instance fields so the same object can be shared across threads and rules.</li>
* </ul>
*
* <p>Called on every request — must not allocate on the hot path.
*
* @see dev.relism.ext.limiter.strategy.FixedWindowStrategy
* @see dev.relism.ext.limiter.strategy.TokenBucketStrategy
*/
public interface RateLimitStrategy {
/**
* Checks whether this request is within the limit and updates the bucket atomically.
*
* <p>On return, {@code out} contains:
* <ul>
* <li>{@code out[0]} — remaining allowed requests in the current window (≥ 0).</li>
* <li>{@code out[1]} — Unix epoch seconds at which the quota resets (for {@code X-RateLimit-Reset}
* and {@code Retry-After} headers).</li>
* </ul>
*
* @param bucket per-key state carrier (pre-allocated, never null)
* @param cfg immutable rule configuration
* @param out caller-supplied two-element array; values are overwritten on every call
* @return {@code true} if the request is within the limit and should proceed
*/
boolean check(Bucket bucket, LimitConfig cfg, long[] out);
}
@@ -0,0 +1,54 @@
package dev.relism.ext.limiter.strategy;
import dev.relism.ext.limiter.Bucket;
import dev.relism.ext.limiter.LimitConfig;
import dev.relism.ext.limiter.RateLimitStrategy;
/**
* Fixed-window rate limit: allows up to {@link LimitConfig#limit()} requests per window of
* {@link LimitConfig#windowMs()} milliseconds. The window is aligned to clock time
* (e.g. 10:00:00 10:00:59 for a 60-second window), not sliding.
*
* <h3>Implementation</h3>
* The entire state fits in a single {@link java.util.concurrent.atomic.AtomicLong}
* ({@link Bucket#slot0}), packed as:
* <pre>
* high 32 bits = reduced epoch (currentTimeMs / windowMs) & 0xFFFFFFFFL
* low 32 bits = request count in the current window
* </pre>
* Each request performs a single CAS loop — no locks, no allocations.
* At a window boundary the CAS atomically resets the counter to 1.
*
* <p>The reduced epoch wraps every {@code 2^32 × windowMs} milliseconds
* (~13,000 years for a 100 ms window) — collision-free in practice.
*/
public final class FixedWindowStrategy implements RateLimitStrategy {
@Override
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
long now = System.currentTimeMillis();
long absEpoch = now / cfg.windowMs();
int epoch = (int)(absEpoch & 0xFFFFFFFFL); // reduced epoch, collision-safe
while (true) {
long packed = bucket.slot0.get();
int storedEpoch = (int)(packed >>> 32);
int count = (int)(packed & 0xFFFFFFFFL);
// Same window: increment; new window: reset to 1.
// Cap at limit+1 to guard against int overflow on extreme traffic.
int newCount = (storedEpoch == epoch)
? Math.min(count + 1, cfg.limit() + 1)
: 1;
long newPacked = ((long) epoch << 32) | (newCount & 0xFFFFFFFFL);
if (bucket.slot0.compareAndSet(packed, newPacked)) {
out[0] = Math.max(0L, cfg.limit() - newCount);
out[1] = (absEpoch + 1) * cfg.windowMs() / 1000L;
return newCount <= cfg.limit();
}
// CAS lost — contention; re-read and retry.
}
}
}
@@ -0,0 +1,69 @@
package dev.relism.ext.limiter.strategy;
import dev.relism.ext.limiter.Bucket;
import dev.relism.ext.limiter.LimitConfig;
import dev.relism.ext.limiter.RateLimitStrategy;
/**
* Token-bucket rate limit: tokens refill continuously at a rate of
* {@code limit / windowMs} tokens per millisecond, up to a maximum of {@code limit} tokens.
* Each request consumes one token. Burst traffic is absorbed until the bucket empties.
*
* <h3>Implementation</h3>
* <ul>
* <li>{@link Bucket#slot0} — current token count scaled by {@value #SCALE}
* (allows sub-token precision without floating-point). Starts at 0; treated as
* {@code maxScaled} when {@link Bucket#slot1} is 0 (first call → bucket starts full).</li>
* <li>{@link Bucket#slot1} — last-refill timestamp in ms. 0 = not yet initialised.</li>
* </ul>
*
* <p>Each request CAS-loops on {@code slot0}; {@code slot1} is updated best-effort after a
* successful CAS. The resulting inaccuracy is bounded by the nanoseconds between the CAS
* and the {@code set} — negligible and self-correcting for rate limiting purposes.
*/
public final class TokenBucketStrategy implements RateLimitStrategy {
/** Fixed-point scale factor. Stored tokens = actual tokens × SCALE. */
static final long SCALE = 1_000L;
@Override
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
long now = System.currentTimeMillis();
long maxScaled = (long) cfg.limit() * SCALE;
// Refill rate: limit tokens per windowMs → (limit * SCALE) / windowMs scaled-tokens per ms.
// Minimum 1 to ensure progress even for very large windows.
long rfPerMs = Math.max(1L, maxScaled / cfg.windowMs());
while (true) {
long lastMs = bucket.slot1.get();
long rawTokens = bucket.slot0.get();
// When slot1 == 0 the bucket has never been used: treat as one full window elapsed
// so the bucket starts completely full.
long elapsed = (lastMs == 0L) ? cfg.windowMs() : Math.max(0L, now - lastMs);
long currentTokens = Math.min(maxScaled, rawTokens + elapsed * rfPerMs);
if (currentTokens < SCALE) {
// Not enough for one token — compute when the next token arrives.
long needed = SCALE - currentTokens;
long msToNext = (needed + rfPerMs - 1) / rfPerMs; // ceiling division
out[0] = 0L;
out[1] = (now + msToNext) / 1000L;
// Best-effort: advance the refill baseline so the next call gets a fresh elapsed.
bucket.slot0.compareAndSet(rawTokens, currentTokens);
bucket.slot1.compareAndSet(lastMs, now);
return false;
}
long newTokens = currentTokens - SCALE;
if (bucket.slot0.compareAndSet(rawTokens, newTokens)) {
// Consumed successfully. Update refill baseline best-effort.
bucket.slot1.set(now);
out[0] = newTokens / SCALE;
out[1] = now / 1000L;
return true;
}
// CAS lost — another thread consumed a token concurrently; re-read and retry.
}
}
}
@@ -1,6 +1,6 @@
package dev.relism.ext.oidc; package dev.relism.ext.oidc;
import dev.relism.extension.ExtensionContext; import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashExtension; import dev.relism.extension.FlashExtension;
import dev.relism.extension.FlashRegistrar; import dev.relism.extension.FlashRegistrar;
@@ -59,7 +59,7 @@ public class OidcExtension implements FlashExtension {
} }
@Override @Override
public void install(FlashRegistrar app, ExtensionContext ctx) { public void install(FlashRegistrar app, FlashContext 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);
@@ -294,7 +294,7 @@ public class OidcExtension implements FlashExtension {
* If not present, the {@link NoClassDefFoundError} is caught at the call site. * If not present, the {@link NoClassDefFoundError} is caught at the call site.
*/ */
private static final class OpenApiIntegration { private static final class OpenApiIntegration {
static void register(dev.relism.extension.ExtensionContext ctx, static void register(dev.relism.extension.FlashContext ctx,
OidcConfig config, OidcProviderMetadata meta) { OidcConfig config, OidcProviderMetadata meta) {
ctx.find(dev.relism.ext.openapi.OpenApiSecurityRegistry.class) ctx.find(dev.relism.ext.openapi.OpenApiSecurityRegistry.class)
.ifPresent(registry -> registry.add(new dev.relism.ext.openapi.OpenApiSecurityContributor() { .ifPresent(registry -> registry.add(new dev.relism.ext.openapi.OpenApiSecurityContributor() {
@@ -13,7 +13,7 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
* Request-level OIDC middleware. Exposed in the {@link dev.relism.extension.ExtensionContext} * Request-level OIDC middleware. Exposed in the {@link dev.relism.extension.FlashContext}
* for manual use on lambda routes; injected automatically for handlers annotated with * for manual use on lambda routes; injected automatically for handlers annotated with
* {@link Authenticated} or {@link RolesAllowed}. * {@link Authenticated} or {@link RolesAllowed}.
* *
+1 -1
View File
@@ -121,7 +121,7 @@ builder picks it up automatically — no coupling between extensions.
### How it works ### How it works
1. `OpenApiExtension` creates an `OpenApiSecurityRegistry` and exposes it in the `ExtensionContext`. 1. `OpenApiExtension` creates an `OpenApiSecurityRegistry` and exposes it in the `FlashContext`.
2. `flash-ext-oidc` calls `ctx.find(OpenApiSecurityRegistry.class)` and registers its contributor. 2. `flash-ext-oidc` calls `ctx.find(OpenApiSecurityRegistry.class)` and registers its contributor.
3. At spec build time, `OpenApiBuilder` iterates contributors and injects `security` entries on each 3. At spec build time, `OpenApiBuilder` iterates contributors and injects `security` entries on each
operation whose handler class carries `@Authenticated` or `@RolesAllowed`. operation whose handler class carries `@Authenticated` or `@RolesAllowed`.
@@ -2,7 +2,7 @@ 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.FlashContext;
import dev.relism.extension.FlashExtension; import dev.relism.extension.FlashExtension;
import dev.relism.extension.FlashRegistrar; import dev.relism.extension.FlashRegistrar;
import dev.relism.http.ContentType; import dev.relism.http.ContentType;
@@ -61,7 +61,7 @@ public class OpenApiExtension implements FlashExtension {
} }
@Override @Override
public void install(FlashRegistrar app, ExtensionContext ctx) { public void install(FlashRegistrar app, FlashContext ctx) {
ObjectMapper jsonMapper = ctx.require(ObjectMapper.class); ObjectMapper jsonMapper = ctx.require(ObjectMapper.class);
YAMLMapper yamlMapper = new YAMLMapper(); YAMLMapper yamlMapper = new YAMLMapper();
@@ -8,7 +8,7 @@ import java.util.Map;
* *
* <p>Extensions that enforce authentication (e.g. {@code flash-ext-oidc}) implement * <p>Extensions that enforce authentication (e.g. {@code flash-ext-oidc}) implement
* this interface and register an instance into {@link OpenApiSecurityRegistry} via the * this interface and register an instance into {@link OpenApiSecurityRegistry} via the
* {@link dev.relism.extension.ExtensionContext}. {@link OpenApiExtension} picks it up * {@link dev.relism.extension.FlashContext}. {@link OpenApiExtension} picks it up
* at spec-generation time — no coupling between the two extensions at install time. * at spec-generation time — no coupling between the two extensions at install time.
* *
* <p>Multi-tenant: multiple contributors may coexist. For handlers secured by * <p>Multi-tenant: multiple contributors may coexist. For handlers secured by
@@ -7,7 +7,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
/** /**
* Mutable registry of {@link OpenApiSecurityContributor}s. * Mutable registry of {@link OpenApiSecurityContributor}s.
* *
* <p>Created and provided to the {@link dev.relism.extension.ExtensionContext} by * <p>Created and provided to the {@link dev.relism.extension.FlashContext} by
* {@link OpenApiExtension} at install time. Other extensions (e.g. {@code flash-ext-oidc}) * {@link OpenApiExtension} at install time. Other extensions (e.g. {@code flash-ext-oidc})
* retrieve it via {@code ctx.find(OpenApiSecurityRegistry.class)} and register their * retrieve it via {@code ctx.find(OpenApiSecurityRegistry.class)} and register their
* contributor — the OpenAPI extension then picks it up lazily at spec-generation time. * contributor — the OpenAPI extension then picks it up lazily at spec-generation time.
File diff suppressed because it is too large Load Diff
@@ -16,7 +16,7 @@ const ROOT_GAP = 48 // vertical gap between independent subtrees (different
const MARGIN_X = 60 const MARGIN_X = 60
const MARGIN_Y = 60 const MARGIN_Y = 60
const ABSTRACT_W = 160 const ABSTRACT_W = 220
const ABSTRACT_H = 50 const ABSTRACT_H = 50
const CONCRETE_W = 260 const CONCRETE_W = 260
const LAMBDA_W = 230 const LAMBDA_W = 230
@@ -1,5 +1,11 @@
import { Handle, Position } from '@xyflow/react' import { Handle, Position } from '@xyflow/react'
// Invisible handles — React Flow needs them as edge anchor points,
// but they must not appear as interactive dots in the read-only graph.
const HANDLE_STYLE = { opacity: 0, width: 6, height: 6, pointerEvents: 'none', border: 'none', background: 'transparent' }
const TARGET_HANDLE = <Handle type="target" position={Position.Left} style={HANDLE_STYLE} />
const SOURCE_HANDLE = <Handle type="source" position={Position.Right} style={HANDLE_STYLE} />
const METHOD_COLORS = { const METHOD_COLORS = {
GET: { bg: '#0d4429', color: '#4ade80' }, GET: { bg: '#0d4429', color: '#4ade80' },
POST: { bg: '#172554', color: '#60a5fa' }, POST: { bg: '#172554', color: '#60a5fa' },
@@ -10,15 +16,17 @@ const METHOD_COLORS = {
HEAD: { bg: '#1c1917', color: '#a8a29e' }, HEAD: { bg: '#1c1917', color: '#a8a29e' },
} }
// Handles for LR layout: parent flows in from the LEFT, children exit to the RIGHT // Shared truncation style — applied to any single-line text that can overflow.
const TARGET_HANDLE = <Handle type="target" position={Position.Left} const TRUNCATE = {
style={{ left: 0, top: '50%', transform: 'translateY(-50%)' }} /> overflow: 'hidden',
const SOURCE_HANDLE = <Handle type="source" position={Position.Right} textOverflow: 'ellipsis',
style={{ right: 0, top: '50%', transform: 'translateY(-50%)' }} /> whiteSpace: 'nowrap',
}
/** /**
* Unified handler node — three modes: ABSTRACT, CONCRETE, LAMBDA. * Unified handler node — three modes: ABSTRACT, CONCRETE, LAMBDA.
* Handles are Left (in) / Right (out) for Left-to-Right DAG layout. * No connection handles: the graph is a read-only visualisation.
* Text is clamped to the node width and truncated with an ellipsis.
*/ */
export default function HandlerNode({ data, selected }) { export default function HandlerNode({ data, selected }) {
@@ -30,16 +38,17 @@ export default function HandlerNode({ data, selected }) {
border: selected ? '2px solid #60a5fa' : '1px solid #ef4444', border: selected ? '2px solid #60a5fa' : '1px solid #ef4444',
borderRadius: 8, borderRadius: 8,
padding: '8px 14px', padding: '8px 14px',
width: 160, width: 220,
fontFamily: 'system-ui, sans-serif', overflow: 'hidden',
boxSizing: 'border-box', boxSizing: 'border-box',
fontFamily: 'system-ui, sans-serif',
}}> }}>
{TARGET_HANDLE} {TARGET_HANDLE}
{SOURCE_HANDLE} {SOURCE_HANDLE}
<div style={{ fontSize: 9, color: '#ef4444', fontWeight: 700, letterSpacing: 0.5, marginBottom: 3 }}> <div style={{ fontSize: 9, color: '#ef4444', fontWeight: 700, letterSpacing: 0.5, marginBottom: 3 }}>
ABSTRACT ABSTRACT
</div> </div>
<div style={{ fontSize: 12, fontWeight: 600, color: '#e2e8f0', fontFamily: 'monospace' }}> <div style={{ fontSize: 12, fontWeight: 600, color: '#e2e8f0', fontFamily: 'monospace', ...TRUNCATE }}>
{data.name} {data.name}
</div> </div>
</div> </div>
@@ -48,9 +57,9 @@ export default function HandlerNode({ data, selected }) {
// ── LAMBDA ──────────────────────────────────────────────────────────── // ── LAMBDA ────────────────────────────────────────────────────────────
if (data.isLambda) { if (data.isLambda) {
const method = data.method || 'GET' const method = data.method || 'GET'
const path = data.path || '/' const path = data.path || '/'
const m = METHOD_COLORS[method] || METHOD_COLORS.OPTIONS const m = METHOD_COLORS[method] || METHOD_COLORS.OPTIONS
const pathHtml = path.replace(/\{([^}]+)\}/g, '<span style="color:#a78bfa">{$1}</span>') const pathHtml = path.replace(/\{([^}]+)\}/g, '<span style="color:#a78bfa">{$1}</span>')
return ( return (
@@ -60,15 +69,16 @@ export default function HandlerNode({ data, selected }) {
borderRadius: 8, borderRadius: 8,
padding: '10px 12px', padding: '10px 12px',
width: 230, width: 230,
fontFamily: 'system-ui, sans-serif', overflow: 'hidden',
boxSizing: 'border-box', boxSizing: 'border-box',
fontFamily: 'system-ui, sans-serif',
}}> }}>
{TARGET_HANDLE} {TARGET_HANDLE}
{SOURCE_HANDLE} {SOURCE_HANDLE}
<div style={{ fontSize: 9, color: '#60a5fa', fontWeight: 700, letterSpacing: 0.5, marginBottom: 7 }}> <div style={{ fontSize: 9, color: '#60a5fa', fontWeight: 700, letterSpacing: 0.5, marginBottom: 7 }}>
LAMBDA LAMBDA
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, overflow: 'hidden' }}>
<span style={{ <span style={{
background: m.bg, color: m.color, background: m.bg, color: m.color,
fontSize: 9, fontWeight: 700, padding: '2px 5px', fontSize: 9, fontWeight: 700, padding: '2px 5px',
@@ -77,7 +87,7 @@ export default function HandlerNode({ data, selected }) {
{method} {method}
</span> </span>
<span <span
style={{ fontSize: 10, fontFamily: 'monospace', color: '#8892a4' }} style={{ fontSize: 10, fontFamily: 'monospace', color: '#8892a4', ...TRUNCATE }}
dangerouslySetInnerHTML={{ __html: pathHtml }} dangerouslySetInnerHTML={{ __html: pathHtml }}
/> />
</div> </div>
@@ -88,6 +98,7 @@ export default function HandlerNode({ data, selected }) {
background: '#1f1a0e', color: '#f6ad55', background: '#1f1a0e', color: '#f6ad55',
fontSize: 8, fontWeight: 600, padding: '1px 4px', fontSize: 8, fontWeight: 600, padding: '1px 4px',
borderRadius: 2, fontFamily: 'monospace', borderRadius: 2, fontFamily: 'monospace',
...TRUNCATE, maxWidth: '100%',
}}>{mw}</span> }}>{mw}</span>
))} ))}
</div> </div>
@@ -104,18 +115,18 @@ export default function HandlerNode({ data, selected }) {
borderRadius: 8, borderRadius: 8,
padding: '10px 12px', padding: '10px 12px',
width: 260, width: 260,
fontFamily: 'system-ui, sans-serif', overflow: 'hidden',
boxSizing: 'border-box', boxSizing: 'border-box',
fontFamily: 'system-ui, sans-serif',
}}> }}>
{TARGET_HANDLE} {TARGET_HANDLE}
{SOURCE_HANDLE} {SOURCE_HANDLE}
{/* Header */} {/* Header */}
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 9, color: '#60a5fa', fontWeight: 700, letterSpacing: 0.5, marginBottom: 2 }}> <div style={{ fontSize: 9, color: '#60a5fa', fontWeight: 700, letterSpacing: 0.5, marginBottom: 2 }}>
HANDLER HANDLER
</div> </div>
<div style={{ fontSize: 12, fontWeight: 700, color: '#e2e8f0', fontFamily: 'monospace' }}> <div style={{ fontSize: 12, fontWeight: 700, color: '#e2e8f0', fontFamily: 'monospace', ...TRUNCATE }}>
{data.name} {data.name}
</div> </div>
</div> </div>
@@ -125,7 +136,7 @@ export default function HandlerNode({ data, selected }) {
{/* Routes */} {/* Routes */}
<div style={{ marginBottom: data.middleware?.length > 0 ? 8 : 0 }}> <div style={{ marginBottom: data.middleware?.length > 0 ? 8 : 0 }}>
{data.routes?.map((route, i) => { {data.routes?.map((route, i) => {
const m = METHOD_COLORS[route.method] || METHOD_COLORS.OPTIONS const m = METHOD_COLORS[route.method] || METHOD_COLORS.OPTIONS
const pathHtml = (route.path || '/').replace( const pathHtml = (route.path || '/').replace(
/\{([^}]+)\}/g, /\{([^}]+)\}/g,
'<span style="color:#a78bfa">{$1}</span>' '<span style="color:#a78bfa">{$1}</span>'
@@ -134,6 +145,7 @@ export default function HandlerNode({ data, selected }) {
<div key={i} style={{ <div key={i} style={{
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 6,
marginBottom: i < data.routes.length - 1 ? 5 : 0, marginBottom: i < data.routes.length - 1 ? 5 : 0,
overflow: 'hidden',
}}> }}>
<span style={{ <span style={{
background: m.bg, color: m.color, background: m.bg, color: m.color,
@@ -143,7 +155,7 @@ export default function HandlerNode({ data, selected }) {
{route.method} {route.method}
</span> </span>
<span <span
style={{ fontSize: 10, fontFamily: 'monospace', color: '#8892a4' }} style={{ fontSize: 10, fontFamily: 'monospace', color: '#8892a4', ...TRUNCATE }}
dangerouslySetInnerHTML={{ __html: pathHtml }} dangerouslySetInnerHTML={{ __html: pathHtml }}
/> />
</div> </div>
@@ -159,6 +171,7 @@ export default function HandlerNode({ data, selected }) {
background: '#1f1a0e', color: '#f6ad55', background: '#1f1a0e', color: '#f6ad55',
fontSize: 8, fontWeight: 600, padding: '1px 4px', fontSize: 8, fontWeight: 600, padding: '1px 4px',
borderRadius: 2, fontFamily: 'monospace', borderRadius: 2, fontFamily: 'monospace',
...TRUNCATE, maxWidth: '100%',
}}>{mw}</span> }}>{mw}</span>
))} ))}
</div> </div>
@@ -1,7 +1,7 @@
package dev.relism.ext.routeviewer; package dev.relism.ext.routeviewer;
import dev.relism.ext.routeviewer.model.RouteGraph; import dev.relism.ext.routeviewer.model.RouteGraph;
import dev.relism.extension.ExtensionContext; import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashExtension; import dev.relism.extension.FlashExtension;
import dev.relism.extension.FlashRegistrar; import dev.relism.extension.FlashRegistrar;
import dev.relism.http.ContentType; import dev.relism.http.ContentType;
@@ -36,7 +36,7 @@ import dev.relism.http.ContentType;
* }</pre> * }</pre>
* *
* <p>All route metadata is collected once at boot time via * <p>All route metadata is collected once at boot time via
* {@link ExtensionContext#addRouteListener}. Zero overhead on the request hot-path. * {@link FlashContext#addRouteListener}. Zero overhead on the request hot-path.
*/ */
public class RouteViewerExtension implements FlashExtension { public class RouteViewerExtension implements FlashExtension {
@@ -55,7 +55,7 @@ public class RouteViewerExtension implements FlashExtension {
public RouteViewerExtension(String path) { this.path = path; } public RouteViewerExtension(String path) { this.path = path; }
@Override @Override
public void install(FlashRegistrar app, ExtensionContext ctx) { public void install(FlashRegistrar app, FlashContext ctx) {
RouteViewerHandler shell = new RouteViewerHandler(); RouteViewerHandler shell = new RouteViewerHandler();
RouteViewerDataHandler data = new RouteViewerDataHandler(graph); RouteViewerDataHandler data = new RouteViewerDataHandler(graph);
@@ -47,6 +47,11 @@ public record RouteRecord(
/** /**
* Walks the superclass chain, stopping before {@code RequestHandler}. * Walks the superclass chain, stopping before {@code RequestHandler}.
* {@code RequestHandler} is the universal root — showing it adds no information. * {@code RequestHandler} is the universal root — showing it adds no information.
*
* <p>Names are produced by {@link #displayName(Class)} so that static inner classes
* appear as {@code OuterClass.InnerClass} (e.g. {@code PostHandlers.List}) rather
* than the ambiguous simple name ({@code List}). This guarantees globally unique
* display labels in the React Flow graph regardless of inner-class naming collisions.
*/ */
private static List<String> buildAbstractionChain(Class<?> cls) { private static List<String> buildAbstractionChain(Class<?> cls) {
if (cls == null) return List.of(); if (cls == null) return List.of();
@@ -54,12 +59,31 @@ public record RouteRecord(
Class<?> c = cls; Class<?> c = cls;
while (c != null && !c.equals(Object.class)) { while (c != null && !c.equals(Object.class)) {
if (ROOT_HANDLER.equals(c.getSimpleName())) break; if (ROOT_HANDLER.equals(c.getSimpleName())) break;
chain.add(c.getSimpleName()); chain.add(displayName(c));
c = c.getSuperclass(); c = c.getSuperclass();
} }
return List.copyOf(chain); return List.copyOf(chain);
} }
/**
* Returns a human-readable, globally unique display name for a handler class.
*
* <ul>
* <li>Top-level class {@code HtmlHandler} → {@code "HtmlHandler"}</li>
* <li>Static inner class {@code PostHandlers$List} → {@code "PostHandlers.List"}</li>
* <li>Deeply nested {@code A$B$C} → {@code "A.B.C"}</li>
* </ul>
*
* Strategy: take {@code c.getName()} (binary name with {@code $} separators),
* strip the package prefix, then replace {@code $} with {@code .}.
*/
private static String displayName(Class<?> c) {
String binary = c.getName(); // e.g. dev.relism.bench.handler.api.PostHandlers$List
int lastDot = binary.lastIndexOf('.');
String local = lastDot >= 0 ? binary.substring(lastDot + 1) : binary; // PostHandlers$List
return local.replace('$', '.'); // PostHandlers.List
}
private static List<String> buildPointcuts(Class<?> cls) { private static List<String> buildPointcuts(Class<?> cls) {
if (cls == null) return List.of(); if (cls == null) return List.of();
List<String> pointcuts = new ArrayList<>(); List<String> pointcuts = new ArrayList<>();
File diff suppressed because one or more lines are too long
+44
View File
@@ -0,0 +1,44 @@
<?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-view</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<!--
Optional engine bridges — not transitive.
Users must add whichever engine they select via ViewEngineType
to their own pom.xml. If absent at runtime, ViewExtension throws
a descriptive IllegalStateException at boot time.
-->
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
<version>3.1.2.RELEASE</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,98 @@
package dev.relism.ext.view;
import dev.relism.http.ContentType;
import dev.relism.models.Response;
/**
* Imperative view renderer — the programmatic counterpart to {@link View @View}.
*
* <p>Retrieve once at boot time in {@link dev.relism.models.RequestHandler#onInit onInit()},
* cache in a private field, and call on the hot-path with zero lookup overhead:
*
* <pre>{@code
* @Route(method = HttpMethod.GET, path = "/dashboard")
* public class DashboardHandler extends RequestHandler {
* private Renderer renderer;
* private DashboardService svc;
*
* @Override protected void onInit() {
* renderer = require(Renderer.class);
* svc = require(DashboardService.class);
* }
*
* @Override public Object handle(Request req, Response res) throws Exception {
* return renderer.view(res, "dashboard", Map.of("data", svc.stats()));
* }
* }
* }</pre>
*
* <p>For lambda handlers, capture {@code Renderer} from the context at registration
* time — it is available immediately after {@code ViewExtension} is installed:
*
* <pre>{@code
* app.install(new ViewExtension(engine));
* Renderer renderer = app.ctx().require(Renderer.class);
* app.get("/about", (req, res) -> renderer.view(res, "about"));
* }</pre>
*
* <p>The underlying {@link ViewEngine} is thread-safe after construction — no
* synchronization is needed on the hot-path.
*/
public final class Renderer {
private final ViewEngine engine;
/** Package-private — constructed exclusively by {@link ViewExtension}. */
Renderer(ViewEngine engine) {
this.engine = engine;
}
// ── Rendering ────────────────────────────────────────────────────────────
/**
* Renders {@code template} with {@code model}, sets the {@code Content-Type}
* header to {@code type}, and returns the rendered string as the handler body.
*/
public String view(Response res, String template, Object model, ContentType type) throws Exception {
res.setContentType(type);
return engine.render(template, model);
}
/**
* Renders {@code template} with {@code model} and sets
* {@code Content-Type: text/html}.
*/
public String view(Response res, String template, Object model) throws Exception {
return view(res, template, model, ContentType.TEXT_HTML);
}
/** Renders {@code template} with a {@code null} model. */
public String view(Response res, String template) throws Exception {
return view(res, template, null);
}
// ── Template signal ───────────────────────────────────────────────────────
/**
* Creates a deferred {@link Template} signal that will be intercepted by the
* {@link View @View} middleware. Use this from handlers that carry {@code @View}
* but need to dynamically override the template name or supply a different model.
*
* <p>Does <em>not</em> render immediately — rendering happens in the middleware.
*/
public Template template(String name, Object model) {
return Template.of(name, model);
}
/** Creates a {@link Template} signal with a {@code null} model. */
public Template template(String name) {
return Template.of(name);
}
// ── Escape hatch ─────────────────────────────────────────────────────────
/** Direct access to the underlying {@link ViewEngine} for advanced use cases. */
public ViewEngine engine() {
return engine;
}
}
@@ -0,0 +1,62 @@
package dev.relism.ext.view;
/**
* Explicit render signal returned from a handler to override the template name
* and/or model chosen by {@link View @View}.
*
* <p>{@code Template} is a lightweight value object — it carries the template
* name and an optional model, but performs no rendering itself. The
* {@link ViewExtension}-injected middleware detects it at the call site and
* delegates to the {@link ViewEngine}.
*
* <p>Use {@code Template} when:
* <ul>
* <li>The handler is annotated with {@code @View} but needs to redirect to a
* different template dynamically (e.g. on validation failure).</li>
* <li>A lambda handler or a handler <em>without</em> {@code @View} wants to
* trigger rendering without registering the annotation — pair with a
* {@link Renderer} captured at construction time.</li>
* </ul>
*
* <pre>{@code
* // Inside a @View-annotated handler — overrides the default template on error
* public Object handle(Request req, Response res) {
* if (!valid) return Template.of("form-error", Map.of("errors", errors));
* return service.findAll(); // falls back to @View template
* }
*
* // Lambda handler — pair with Renderer captured from ctx at boot time
* Renderer renderer = ctx.require(Renderer.class);
* app.get("/page", (req, res) -> renderer.view(res, "page", model));
* }</pre>
*/
public final class Template {
private final String name;
private final Object model;
private Template(String name, Object model) {
this.name = name;
this.model = model;
}
/** Creates a {@code Template} signal with the given name and model. */
public static Template of(String name, Object model) {
return new Template(name, model);
}
/** Creates a {@code Template} signal with a {@code null} model. */
public static Template of(String name) {
return new Template(name, null);
}
/** The template name/path to render. */
public String name() {
return name;
}
/** The model to bind; may be {@code null}. */
public Object model() {
return model;
}
}
@@ -0,0 +1,146 @@
package dev.relism.ext.view;
import org.thymeleaf.IEngineConfiguration;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.context.IExpressionContext;
import org.thymeleaf.linkbuilder.ILinkBuilder;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
/**
* {@link ViewEngine} bridge for Thymeleaf 3.x.
*
* <p>Package-private — instantiated exclusively by {@link ViewEngineType#THYMELEAF}.
*
* <h3>Default configuration</h3>
* <ul>
* <li>Prefix : {@code /templates/} (classpath-relative)</li>
* <li>Suffix : {@code .html}</li>
* <li>Mode : {@link TemplateMode#HTML}</li>
* <li>Encoding: UTF-8</li>
* <li>Cache : enabled in production, disabled in dev mode
* ({@code flash.env=dev} or {@code FLASH_ENV=dev})</li>
* </ul>
*
* <h3>Link building</h3>
* Thymeleaf's built-in {@code StandardLinkBuilder} requires an
* {@code IWebContext} (servlet context) to resolve context-relative paths
* ({@code @{/foo}}). Flash runs standalone, so this engine registers a custom
* {@link FlashLinkBuilder} that resolves {@code @{...}} expressions without a
* servlet context — path variables and query parameters are supported as usual.
*
* <h3>Model conventions</h3>
* <ul>
* <li>{@link Map} model → each entry is a named Thymeleaf variable.</li>
* <li>Any other non-null value → registered under the key {@code "it"}.</li>
* <li>{@code null} model → empty context.</li>
* </ul>
*/
final class ThymeleafEngine implements ViewEngine {
private static final String PREFIX = "/templates/";
private static final String SUFFIX = ".html";
private static final String FRAGMENT = " :: content";
private final TemplateEngine engine;
ThymeleafEngine(boolean cacheEnabled) {
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
resolver.setPrefix(PREFIX);
resolver.setSuffix(SUFFIX);
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCharacterEncoding("UTF-8");
resolver.setCacheable(cacheEnabled);
this.engine = new TemplateEngine();
this.engine.setTemplateResolver(resolver);
// Replace the default StandardLinkBuilder (which requires IWebContext)
// with our standalone-compatible link builder.
this.engine.addLinkBuilder(FlashLinkBuilder.INSTANCE);
}
@Override
public String render(String template, Object model, boolean fragment) {
Context ctx = new Context();
populateContext(ctx, model);
return engine.process(fragment ? template + FRAGMENT : template, ctx);
}
private static void populateContext(Context ctx, Object model) {
if (model instanceof Map<?, ?> map) {
map.forEach((k, v) -> ctx.setVariable(String.valueOf(k), v));
} else if (model != null) {
ctx.setVariable("it", model);
}
}
// ── Link builder ──────────────────────────────────────────────────────────
/**
* Standalone-compatible link builder for Thymeleaf's {@code @{...}} expressions.
*
* <p>Thymeleaf's built-in {@code StandardLinkBuilder} requires an
* {@code IWebContext} (i.e. a servlet container) to resolve context-relative
* paths starting with {@code /}. This builder replicates that behaviour without
* the servlet dependency:
* <ul>
* <li>Path variables — {@code @{/posts/{id}(id=${post.id})}} → {@code /posts/abc}</li>
* <li>Query params — {@code @{/search(q=${term})}} → {@code /search?q=hello}</li>
* <li>Mixed — {@code @{/posts/{id}(id=x,p=2)}} → {@code /posts/x?p=2}</li>
* </ul>
* Registered at order {@link Integer#MIN_VALUE} so it takes precedence over
* {@code StandardLinkBuilder} ({@code Integer.MAX_VALUE}).
*/
private static final class FlashLinkBuilder implements ILinkBuilder {
static final FlashLinkBuilder INSTANCE = new FlashLinkBuilder();
@Override public String getName() { return "flash"; }
@Override public Integer getOrder() { return Integer.MIN_VALUE; }
@Override
public String buildLink(IExpressionContext ctx,
String base,
Map<String, Object> params) {
if (base == null) return "";
String url = expandPathVars(base, params);
return appendQueryString(url, base, params);
}
/** Substitutes {@code {key}} placeholders in the path with their encoded values. */
private static String expandPathVars(String base, Map<String, Object> params) {
if (params == null || params.isEmpty() || !base.contains("{")) return base;
String result = base;
for (var e : params.entrySet()) {
String placeholder = '{' + e.getKey() + '}';
if (result.contains(placeholder) && e.getValue() != null) {
result = result.replace(placeholder, encode(String.valueOf(e.getValue())));
}
}
return result;
}
/** Appends parameters that were NOT consumed as path variables as {@code ?k=v&…} pairs. */
private static String appendQueryString(String url, String base, Map<String, Object> params) {
if (params == null || params.isEmpty()) return url;
StringBuilder qs = new StringBuilder();
for (var e : params.entrySet()) {
if (base.contains('{' + e.getKey() + '}') || e.getValue() == null) continue;
qs.append(qs.isEmpty() ? '?' : '&')
.append(encode(e.getKey()))
.append('=')
.append(encode(String.valueOf(e.getValue())));
}
return qs.isEmpty() ? url : url + qs;
}
private static String encode(String s) {
return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20");
}
}
}
@@ -0,0 +1,75 @@
package dev.relism.ext.view;
import dev.relism.http.ContentType;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Declarative view binding for class-based handlers.
*
* <p>When {@code ViewExtension} is installed, handlers annotated with {@code @View}
* receive an injected middleware that intercepts the handler's return value and
* passes it to the {@link ViewEngine} for rendering. The rendered string replaces
* the handler's return value as the response body, and {@link #contentType()} is
* written to the {@code Content-Type} header.
*
* <h3>Return-value semantics</h3>
* <ul>
* <li>Return a {@link Template} — overrides both the template name <em>and</em>
* the model dynamically (e.g. redirect to a different template on error).</li>
* <li>Return any other non-null value — used as the model; the template name
* comes from {@link #value()}.</li>
* <li>Return {@code null} — renders {@link #value()} with a {@code null} model.</li>
* </ul>
*
* <pre>{@code
* @Route(method = HttpMethod.GET, path = "/")
* @View("home")
* public class HomeHandler extends RequestHandler {
* private PostService posts;
* @Override protected void onInit() { posts = require(PostService.class); }
*
* @Override public Object handle(Request req, Response res) {
* return Map.of("posts", posts.findAll()); // model → home template
* }
* }
*
* // Dynamic template override via Template signal
* @View("list")
* public class ConditionalHandler extends RequestHandler {
* public Object handle(Request req, Response res) {
* if (something) return Template.of("error", Map.of("msg", "oops"));
* return data; // uses "list" template
* }
* }
* }</pre>
*
* <p>The annotation is inspected via superclass traversal, so a base handler class
* can declare the view template and all concrete subclasses inherit it.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface View {
/**
* Template name or path passed to the {@link ViewEngine}.
* The exact format is engine-specific (e.g. {@code "home"}, {@code "views/home.html"}).
*/
String value();
/**
* {@code Content-Type} written to the response.
* Defaults to {@link ContentType#TEXT_HTML}.
*/
ContentType contentType() default ContentType.TEXT_HTML;
/**
* If {@code true}, instructs the {@link ViewEngine} to render only a named
* fragment inside the template rather than the full page.
* Useful for HTMX / partial-update patterns.
*/
boolean fragment() default false;
}
@@ -0,0 +1,45 @@
package dev.relism.ext.view;
/**
* Contract for template engine integrations.
*
* <p>Implement this interface to plug any template engine (Thymeleaf, Jinjava,
* Mustache, FreeMarker, …) into the Flash view layer. A single instance is
* shared across all handlers, so implementations must be thread-safe.
*
* <pre>{@code
* // Thymeleaf example
* ViewEngine thymeleaf = (template, model, fragment) -> {
* Context ctx = new Context();
* if (model instanceof Map<?,?> m) m.forEach((k, v) -> ctx.setVariable(k.toString(), v));
* else if (model != null) ctx.setVariable("model", model);
* return engine.process(fragment ? template + " :: fragment" : template, ctx);
* };
*
* app.install(new ViewExtension(thymeleaf));
* }</pre>
*/
@FunctionalInterface
public interface ViewEngine {
/**
* Renders {@code template} with the supplied {@code model}.
*
* @param template the template name or path — engine-specific convention
* (e.g. {@code "views/home"}, {@code "home.html"})
* @param model the model object passed to the template; may be {@code null}
* @param fragment if {@code true}, only a named fragment inside the template
* should be rendered (Thymeleaf: {@code template :: fragment},
* Mustache: partial name, etc.)
* @return the rendered output string
* @throws Exception any rendering error — propagated as a 500 by the Flash runtime
*/
String render(String template, Object model, boolean fragment) throws Exception;
/**
* Convenience overload — renders the full template ({@code fragment = false}).
*/
default String render(String template, Object model) throws Exception {
return render(template, model, false);
}
}
@@ -0,0 +1,80 @@
package dev.relism.ext.view;
/**
* Managed template engine types supported out-of-the-box by {@link ViewExtension}.
*
* <p>Pass one of these constants to {@link ViewExtension#ViewExtension(ViewEngineType)}
* for zero-boilerplate setup. The extension auto-configures the selected engine with
* sensible defaults and validates that the required library is on the runtime classpath,
* throwing a descriptive {@link IllegalStateException} at boot time if it is not.
*
* <pre>{@code
* // Zero-boilerplate — Thymeleaf auto-configured with defaults
* app.install(new ViewExtension(ViewEngineType.THYMELEAF));
* }</pre>
*
* <h3>Dev mode</h3>
* Template caching is <b>disabled</b> when either:
* <ul>
* <li>the JVM property {@code flash.env} equals {@code dev} (case-insensitive), or</li>
* <li>the environment variable {@code FLASH_ENV} equals {@code dev}.</li>
* </ul>
* In all other cases caching is enabled (production default).
*
* <h3>Adding your own engine</h3>
* For unsupported engines, implement {@link ViewEngine} directly and use
* {@link ViewExtension#ViewExtension(ViewEngine)} instead.
*/
public enum ViewEngineType {
/**
* Thymeleaf 3.x — natural HTML templates with server-side rendering.
*
* <p>Required dependency (add to your {@code pom.xml}):
* <pre>{@code
* <dependency>
* <groupId>org.thymeleaf</groupId>
* <artifactId>thymeleaf</artifactId>
* <version>3.1.2.RELEASE</version>
* </dependency>
* }</pre>
*
* Default resolver: classpath, prefix {@code /templates/}, suffix {@code .html},
* mode {@code HTML}, encoding UTF-8.
*/
THYMELEAF;
// ── Factory ───────────────────────────────────────────────────────────────
/**
* Instantiates and configures the {@link ViewEngine} for this type.
* Called once at {@link ViewExtension#install} time — never on the hot-path.
*
* @param cacheEnabled whether the engine should cache compiled templates
* @throws IllegalStateException if the required library is not on the classpath
*/
ViewEngine createEngine(boolean cacheEnabled) {
return switch (this) {
case THYMELEAF -> createThymeleaf(cacheEnabled);
};
}
// ── Engine factories ──────────────────────────────────────────────────────
private static ViewEngine createThymeleaf(boolean cacheEnabled) {
try {
return new ThymeleafEngine(cacheEnabled);
} catch (NoClassDefFoundError e) {
throw new IllegalStateException("""
Thymeleaf is not on the classpath. \
Add the following dependency to your pom.xml:
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
<version>3.1.2.RELEASE</version>
</dependency>
""", e);
}
}
}
@@ -0,0 +1,166 @@
package dev.relism.ext.view;
import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashExtension;
import dev.relism.extension.FlashRegistrar;
import dev.relism.http.ContentType;
import dev.relism.routing.Middleware;
import java.util.List;
import java.util.Objects;
/**
* Installs the view layer into a Flash application.
*
* <h3>Managed mode (recommended)</h3>
* Pass a {@link ViewEngineType} — the extension auto-configures the engine,
* detects dev mode for cache settings, and validates classpath dependencies at boot:
* <pre>{@code
* app.install(new ViewExtension(ViewEngineType.THYMELEAF));
* }</pre>
*
* <h3>Manual mode (BYOE)</h3>
* Supply your own {@link ViewEngine} implementation for full control:
* <pre>{@code
* ViewEngine myEngine = (template, model, fragment) -> { ... };
* app.install(new ViewExtension(myEngine));
* }</pre>
*
* <h3>What gets installed</h3>
* <ol>
* <li>{@link ViewEngine} and {@link Renderer} are bound in the {@link FlashContext} —
* any handler can retrieve them via {@code require(Renderer.class)}.</li>
* <li>An {@link dev.relism.extension.AnnotationProcessor} is registered: class-based
* handlers carrying {@link View @View} receive an injected rendering middleware
* that intercepts the handler return value, resolves the template + model, and
* delegates to the engine. No boilerplate required in the handler itself.</li>
* </ol>
*
* <h3>Handler patterns</h3>
* <pre>{@code
* // Declarative — annotation drives template selection
* @Route(method = HttpMethod.GET, path = "/")
* @View("home")
* public class HomeHandler extends RequestHandler {
* public Object handle(Request req, Response res) {
* return Map.of("posts", service.findAll()); // model → home.html
* }
* }
*
* // Dynamic override via Template signal
* @View("list")
* public class ListHandler extends RequestHandler {
* public Object handle(Request req, Response res) {
* if (error) return Template.of("error", Map.of("msg", "oops"));
* return data; // falls back to list.html
* }
* }
*
* // Imperative — explicit render call (lambda-friendly)
* Renderer renderer = app.ctx().require(Renderer.class);
* app.get("/about", (req, res) -> renderer.view(res, "about"));
* }</pre>
*
* <h3>Dev mode / cache</h3>
* In managed mode, template caching is disabled when the JVM property
* {@code flash.env=dev} or the environment variable {@code FLASH_ENV=dev} is set.
*
* <h3>Cross-extension integration</h3>
* <pre>{@code
* ctx.optional(ViewEngine.class).ifPresent(engine -> { ... });
* }</pre>
*/
public final class ViewExtension implements FlashExtension {
private final ViewEngine engine;
// ── Constructors ──────────────────────────────────────────────────────────
/**
* Managed mode — auto-configures the engine selected by {@code type}.
*
* <p>Template caching is enabled unless {@code flash.env=dev} (JVM property)
* or {@code FLASH_ENV=dev} (environment variable) is set.
*
* @param type the engine to use; must have its library on the runtime classpath
* @throws IllegalStateException at boot time if the library is missing
*/
public ViewExtension(ViewEngineType type) {
this(type.createEngine(!isDevMode()));
}
/**
* Manual mode — use a pre-constructed {@link ViewEngine} implementation.
* Suitable for custom engines or engines that need non-default configuration.
*
* @param engine the engine implementation; must be thread-safe
*/
public ViewExtension(ViewEngine engine) {
this.engine = Objects.requireNonNull(engine, "ViewEngine must not be null");
}
// ── FlashExtension ────────────────────────────────────────────────────────
@Override
public void install(FlashRegistrar app, FlashContext ctx) {
Renderer renderer = new Renderer(engine);
ctx.provide(ViewEngine.class, engine);
ctx.provide(Renderer.class, renderer);
ctx.addAnnotationProcessor(handlerClass -> {
View view = findView(handlerClass);
if (view == null) return List.of();
String defaultTemplate = view.value();
ContentType contentType = view.contentType();
boolean fragment = view.fragment();
// Injected once per handler at boot — zero overhead on the hot-path.
// Intercepts the return value: Template signal overrides name+model;
// any other value becomes the model for the annotation's template.
Middleware renderingMiddleware = next -> (req, res) -> {
Object result = next.handle(req, res);
String tpl;
Object model;
if (result instanceof Template t) {
tpl = t.name();
model = t.model();
} else {
tpl = defaultTemplate;
model = result;
}
res.setContentType(contentType);
return engine.render(tpl, model, fragment);
};
return List.of(renderingMiddleware);
});
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Walks the superclass chain to find {@link View @View}.
* Supports inheritance: a base handler can declare the view template and
* concrete subclasses inherit it without re-annotating.
*/
private static View findView(Class<?> cls) {
while (cls != null && !cls.equals(Object.class)) {
View v = cls.getAnnotation(View.class);
if (v != null) return v;
cls = cls.getSuperclass();
}
return null;
}
/**
* Returns {@code true} when running in dev mode.
* Checks JVM property {@code flash.env} first, then env var {@code FLASH_ENV}.
*/
private static boolean isDevMode() {
String prop = System.getProperty("flash.env");
if (prop != null) return "dev".equalsIgnoreCase(prop);
String env = System.getenv("FLASH_ENV");
return "dev".equalsIgnoreCase(env);
}
}
+2
View File
@@ -18,6 +18,8 @@
<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> <module>flash-ext-routeviewer</module>
<module>flash-ext-view</module>
<module>flash-ext-limiter</module>
</modules> </modules>
<dependencyManagement> <dependencyManagement>
+35 -56
View File
@@ -5,11 +5,12 @@ 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.extension.FlashConfiguration; import dev.relism.extension.FlashConfiguration;
import dev.relism.routing.GlobalRouter; import dev.relism.routing.AbstractRouter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.io.*; import java.io.*;
import java.net.InetSocketAddress;
import java.net.ServerSocket; import java.net.ServerSocket;
import java.net.Socket; import java.net.Socket;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -21,32 +22,32 @@ import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
* Pure I/O transport layer. Owns the {@link ServerSocket}, the virtual-thread executor, * Pure I/O transport layer. Owns the {@link ServerSocket}, the virtual-thread
* and the keep-alive accept loop. All routing is delegated to the {@link GlobalRouter} * executor, and the keep-alive accept loop. Routing is delegated to a single
* supplied at construction time. * {@link AbstractRouter}.
* *
* <p>This class is package-private use {@link dev.relism.extension.FlashApp} as the * <p>Package-private : use {@link dev.relism.extension.FlashApp} as the single
* single entry point for creating and configuring a Flash server. * entry point.
*/ */
@Slf4j @Slf4j
class HttpServer implements ServerHandle { class HttpServer implements ServerHandle {
private final FlashConfiguration configuration; private final FlashConfiguration configuration;
private final ServerSocket serverSocket; private final ServerSocket serverSocket;
private final GlobalRouter globalRouter; private final AbstractRouter router;
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor(); private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet(); private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
private volatile boolean stopped = false; private volatile boolean stopped = false;
private final CompletableFuture<Void> readyFuture = new CompletableFuture<>(); private final CompletableFuture<Void> readyFuture = new CompletableFuture<>();
private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8); private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8); private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8); private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8); private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8); private static final byte[] CONNECTION_CLOSE = "Connection: close\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[] 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];
@@ -55,20 +56,12 @@ class HttpServer implements ServerHandle {
DIGITS[i] = String.valueOf(i).getBytes(StandardCharsets.UTF_8); DIGITS[i] = String.valueOf(i).getBytes(StandardCharsets.UTF_8);
} }
/** HttpServer(FlashConfiguration configuration, AbstractRouter router) throws IOException {
* Creates the transport with a pre-built router. Called exclusively by
* {@link dev.relism.extension.FlashApp}.
*
* @param configuration server configuration (port, host, buffer sizes)
* @param globalRouter the fully-wired router to dispatch requests to
*/
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 = globalRouter; this.router = router;
} }
/** Returns a future that completes once the accept loop is running and the server is ready. */
@Override @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);
@@ -90,15 +83,10 @@ class HttpServer implements ServerHandle {
} }
} }
/** Closes all active connections and shuts down the executor. Returns when complete. */
@Override @Override
public CompletableFuture<Void> stop() { public CompletableFuture<Void> stop() {
stopped = true; stopped = true;
try { try { serverSocket.close(); } catch (IOException e) { log.error("Error closing server socket", e); }
serverSocket.close();
} catch (IOException e) {
log.error("Error closing server socket", e);
}
activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) {} }); activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) {} });
executorService.shutdown(); executorService.shutdown();
try { try {
@@ -111,7 +99,7 @@ class HttpServer implements ServerHandle {
return CompletableFuture.completedFuture(null); return CompletableFuture.completedFuture(null);
} }
// ── Hot-path ───────────────────────────────────────────────────────────── // ── Hot-path ─────────────────────────────────────────────────────────────
private void process(Socket socket) { private void process(Socket socket) {
activeSockets.add(socket); activeSockets.add(socket);
@@ -119,36 +107,32 @@ class HttpServer implements ServerHandle {
try (socket; try (socket;
InputStream in = socket.getInputStream(); InputStream in = socket.getInputStream();
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) { OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
RequestParser parser = new RequestParser(configuration.getMaxHeaderBufferSize()); RequestParser parser = new RequestParser(
configuration.getMaxHeaderBufferSize(),
(InetSocketAddress) socket.getRemoteSocketAddress());
while (!stopped) { while (!stopped) {
Request request = parser.parse(in); Request request = parser.parse(in);
if (request == null) if (request == null) break;
break;
boolean keepAlive = isKeepAlive(request); boolean keepAlive = isKeepAlive(request);
Response response = new Response(200, ContentType.TEXT_PLAIN); Response response = new Response(200, ContentType.TEXT_PLAIN);
RequestHandler handler = globalRouter.route(request);
RequestHandler handler = router.route(request);
if (handler == null) handler = router.getNotFoundHandler();
try { try {
Object result = handler.handle(request, response); Object result = handler.handle(request, response);
if (result instanceof Response r) if (result instanceof Response r) response = r;
response = r; else if (result != null) response.setBody(result);
else if (result != null)
response.setBody(result);
} catch (Exception ex) { } catch (Exception ex) {
Object result = globalRouter.resolveExceptionHandler(request).handle(ex, request, response); Object result = router.getExceptionHandler().handle(ex, request, response);
if (result instanceof Response r) if (result instanceof Response r) response = r;
response = r; else if (result != null) response.setBody(result);
else if (result != null)
response.setBody(result);
} }
writeResponse(out, response, keepAlive); writeResponse(out, response, keepAlive);
request.drain(); request.drain();
if (!keepAlive) break;
if (!keepAlive)
break;
} }
} catch (IOException e) { } catch (IOException e) {
if (!stopped) { if (!stopped) {
@@ -170,11 +154,6 @@ class HttpServer implements ServerHandle {
|| request.headerEquals("Connection", "keep-alive"); || request.headerEquals("Connection", "keep-alive");
} }
/**
* Writes a complete HTTP response. The fixed-body path (the common case for simple handlers
* like /plaintext) is kept inline; streaming and chunked bodies are delegated to
* {@link #writeStreamingBody} so the JIT can optimise this method aggressively.
*/
private static void writeResponse(OutputStream out, Response response, boolean keepAlive) throws IOException { private static void writeResponse(OutputStream out, Response response, boolean keepAlive) throws IOException {
out.write(HTTP_1_1); out.write(HTTP_1_1);
byte[] statusBytes = response.getStatusBytes(); byte[] statusBytes = response.getStatusBytes();
@@ -10,6 +10,7 @@ import lombok.extern.slf4j.Slf4j;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.InetSocketAddress;
import java.util.Arrays; import java.util.Arrays;
/** /**
@@ -21,15 +22,18 @@ import java.util.Arrays;
public class RequestParser { public class RequestParser {
private static final int INITIAL_BUFFER_SIZE = 8192; private static final int INITIAL_BUFFER_SIZE = 8192;
private final int maxHeaderBufferSize; private final int maxHeaderBufferSize;
private final HeaderMap headerMap = new HeaderMap(); private final InetSocketAddress remoteAddress; // set once per connection, never changes
private final HeaderMap headerMap = new HeaderMap();
private byte[] buffer; private byte[] buffer;
private int bufBase = 0; // absolute start of valid data in buffer private int bufBase = 0; // absolute start of valid data in buffer
private int bufLen = 0; // number of valid bytes from bufBase private int bufLen = 0; // number of valid bytes from bufBase
public RequestParser() { this(64 * 1024); } public RequestParser() { this(64 * 1024, null); }
public RequestParser(int maxHeaderBufferSize) { public RequestParser(int maxHeaderBufferSize) { this(maxHeaderBufferSize, null); }
public RequestParser(int maxHeaderBufferSize, InetSocketAddress remoteAddress) {
this.maxHeaderBufferSize = maxHeaderBufferSize; this.maxHeaderBufferSize = maxHeaderBufferSize;
this.remoteAddress = remoteAddress;
this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)]; this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)];
} }
@@ -133,9 +137,9 @@ public class RequestParser {
RequestLine requestLine = new RequestLine(method, pathView, queryView, protocolView, headerMap); RequestLine requestLine = new RequestLine(method, pathView, queryView, protocolView, headerMap);
if (isChunked) { if (isChunked) {
return Request.forParsed(requestLine, new ChunkedInputStream(in, buffer, bodyStart, preBufLen), -1L, null, 0, 0); return Request.forParsed(requestLine, new ChunkedInputStream(in, buffer, bodyStart, preBufLen), -1L, null, 0, 0, remoteAddress);
} }
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen); return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress);
} }
private static int findEndOfHeader(byte[] buf, int from, int len) { private static int findEndOfHeader(byte[] buf, int from, int len) {
@@ -1,26 +1,22 @@
package dev.relism; package dev.relism;
import dev.relism.extension.FlashConfiguration; import dev.relism.extension.FlashConfiguration;
import dev.relism.routing.GlobalRouter; import dev.relism.routing.AbstractRouter;
import java.io.IOException; import java.io.IOException;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
/** /**
* Public handle to the underlying HTTP transport. Returned by {@link #create} so that * Public handle to the underlying HTTP transport. Returned by {@link #create}
* {@link dev.relism.extension.FlashApp} can start and stop the server without holding * so that {@link dev.relism.extension.FlashApp} can start and stop the server
* a direct reference to the package-private {@link HttpServer}. * without holding a direct reference to the package-private {@link HttpServer}.
*/ */
public interface ServerHandle { public interface ServerHandle {
CompletableFuture<Void> start(); CompletableFuture<Void> start();
CompletableFuture<Void> stop(); CompletableFuture<Void> stop();
/** static ServerHandle create(FlashConfiguration config, AbstractRouter router) throws IOException {
* 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); return new HttpServer(config, router);
} }
} }
@@ -1,7 +0,0 @@
package dev.relism.exceptions;
public class DuplicateNamespaceException extends RuntimeException {
public DuplicateNamespaceException(String namespace) {
super("Router with namespace '" + namespace + "' is already registered.");
}
}
@@ -0,0 +1,18 @@
package dev.relism.exceptions;
/**
* Thrown at boot time when Flash detects a configuration or registration error.
*
* <p>Fail-fast: a clear crash at startup is always preferable to a server that
* starts "empty" and silently drops routes.
*/
public class InitializationException extends RuntimeException {
public InitializationException(String message) {
super(message);
}
public InitializationException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -14,7 +14,7 @@ import java.util.List;
* valid — processors may also use the call purely for side effects * valid — processors may also use the call purely for side effects
* (e.g. collecting OpenAPI metadata). * (e.g. collecting OpenAPI metadata).
* *
* <p>Register processors via {@link ExtensionContext#addAnnotationProcessor}. * <p>Register processors via {@link FlashContext#addAnnotationProcessor}.
*/ */
@FunctionalInterface @FunctionalInterface
public interface AnnotationProcessor { public interface AnnotationProcessor {
@@ -1,14 +1,15 @@
package dev.relism.extension; package dev.relism.extension;
import dev.relism.ServerHandle; import dev.relism.ServerHandle;
import dev.relism.exceptions.InitializationException;
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.Route;
import dev.relism.routing.RouteHandle; import dev.relism.routing.RouteHandle;
import dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
@@ -16,98 +17,62 @@ 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.function.Consumer;
import java.util.stream.Stream;
/** /**
* Primary entry point for Flash. Creates and owns both the {@link GlobalRouter} and * Single entry point for Flash. Owns one flat {@link FastPathRouterImpl} —
* the {@link HttpServer} (pure I/O transport). All route registration goes through * all routes (app-level and scoped) compile into a single FSM at {@link #start()}.
* {@code FlashApp} or a {@link FlashScope} — never through the server or router directly.
* *
* <p>Create via the static factories: * <h3>Deferred routing</h3>
* <pre>{@code * Routes accumulate during the builder phase. At {@code start()}:
* FlashApp app = FlashApp.create(8080); * <ol>
* FlashApp app = FlashApp.create(FlashConfiguration.builder().port(8080).build()); * <li>Global middlewares are prepended to every route</li>
* }</pre> * <li>Annotation processors run for class-based handlers</li>
* <li>Handlers are bound to the {@link FlashContext}</li>
* <li>All routes compile into one FSM — zero prefix scanning at runtime</li>
* </ol>
* *
* <p>Install extensions, register routes, mount namespaces, then start:
* <pre>{@code * <pre>{@code
* FlashApp.create(8080) * FlashApp.create(8080)
* .install(new JacksonExtension()) * .install(new JacksonExtension())
* .install(new OidcExtension(config)) * .use(cors)
* .get("/ping", (req, res) -> "pong") * .get("/ping", (req, res) -> "pong")
* .get("/me", (req, res) -> ClaimsHolder.user().email()).with(oidc.protect()) * .scan("dev.example.handlers")
* .register(new HomePage()) // @Route + annotation processors applied * .mount("/api", scope -> scope.get("/health", (req, res) -> "ok"))
* .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(); * .start();
* }</pre> * }</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 final class FlashApp implements FlashRegistrar { public final class FlashApp implements FlashRegistrar {
private final GlobalRouter router; private final AbstractRouter router = new FastPathRouterImpl();
private final ServerHandle server; private final ServerHandle server;
private final ExtensionContext ctx = new ExtensionContext(); private final FlashContext ctx = new FlashContext();
private final List<Middleware> globalMiddlewares = new ArrayList<>();
private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
/** The last returned RouteHandle not yet registered — auto-flushed before the next operation. */
private RouteHandle<?> pending; private RouteHandle<?> pending;
/**
* 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) { private FlashApp(FlashConfiguration config) {
this.router = new GlobalRouter();
try { try {
this.server = ServerHandle.create(config, router); this.server = ServerHandle.create(config, router);
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException("Failed to bind server socket on port " + config.getPort(), e); throw new InitializationException("Failed to bind on port " + config.getPort(), e);
} }
} }
// ── Factories ───────────────────────────────────────────────────────────── // ── Factories ─────────────────────────────────────────────────────────────
/**
* Creates a {@code FlashApp} listening on {@code port} with default configuration.
*
* @param port the TCP port to bind
*/
public static FlashApp create(int port) { public static FlashApp create(int port) {
return create(FlashConfiguration.builder().port(port).build()); 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) { public static FlashApp create(FlashConfiguration config) {
return new FlashApp(config); return new FlashApp(config);
} }
// ── Pending flush ───────────────────────────────────────────────────────── // ── Pending flush ─────────────────────────────────────────────────────────
/**
* Registers any pending route (from the previous {@code get/post/register} call)
* with no middleware if it has not already been committed via {@link RouteHandle#with}.
*/
private void flushPending() { private void flushPending() {
if (pending != null) { if (pending != null) { pending.ensureRegistered(); pending = null; }
pending.ensureRegistered();
pending = null;
}
} }
private <P> RouteHandle<P> track(RouteHandle<P> handle) { private <P> RouteHandle<P> track(RouteHandle<P> handle) {
@@ -116,15 +81,8 @@ public final class FlashApp implements FlashRegistrar {
return handle; return handle;
} }
// ── FlashRegistrar — extension installation ─────────────────────────────── // ── 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 @Override
public FlashApp install(FlashExtension ext) { public FlashApp install(FlashExtension ext) {
flushPending(); flushPending();
@@ -132,34 +90,12 @@ public final class FlashApp implements FlashRegistrar {
return this; return this;
} }
// ── Global middleware ───────────────────────────────────────────────────── // ── Global middleware ─────────────────────────────────────────────────────
/** /**
* Registers one or more global middlewares applied to <em>every</em> route on this app, * Registers global middlewares applied to <em>every</em> route — including
* regardless of how the route is registered (lambda, class-based, or via {@link #scan}). * routes registered before this call and routes inside mounted scopes.
* * Order-independent: resolved at {@link #start()}.
* <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) { public FlashApp use(Middleware... middlewares) {
flushPending(); flushPending();
@@ -167,182 +103,157 @@ public final class FlashApp implements FlashRegistrar {
return this; return this;
} }
/** // ── Route registration (deferred) ─────────────────────────────────────────
* 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 lambda(HttpMethod.GET, path, h); }
@Override public RouteHandle<FlashApp> post (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.POST, path, h); }
@Override public RouteHandle<FlashApp> put (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.PUT, path, h); }
@Override public RouteHandle<FlashApp> delete (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.DELETE, path, h); }
@Override public RouteHandle<FlashApp> patch (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.PATCH, path, h); }
@Override public RouteHandle<FlashApp> options(String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.OPTIONS, path, h); }
@Override public RouteHandle<FlashApp> head (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.HEAD, path, h); }
@Override public RouteHandle<FlashApp> trace (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.TRACE, path, h); }
@Override public RouteHandle<FlashApp> connect(String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.CONNECT, path, h); }
@Override public RouteHandle<FlashApp> purge (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.PURGE, path, h); }
@Override public RouteHandle<FlashApp> get (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.GET, path, h); } private static final Middleware[] NO_MW = new Middleware[0];
@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) { private RouteHandle<FlashApp> lambda(HttpMethod method, String path, SimpleHandler.FunctionalHandler h) {
return track(new RouteHandle<>(this, m -> { return track(new RouteHandle<>(this, mw ->
Middleware[] all = withGlobal(m); deferredRoutes.add(new RouteDefinition(method, path, new SimpleHandler(h), NO_MW, mw, false, ctx, "/"))));
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 * Registers a class-based handler annotated with {@link Route @Route}.
* {@link Route @Route} annotation. All registered {@link AnnotationProcessor}s * App-level only — not part of {@link FlashRegistrar}.
* 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) { public RouteHandle<FlashApp> register(RequestHandler handler) {
return track(new RouteHandle<>(this, explicit -> { Route ann = handler.getClass().getAnnotation(Route.class);
List<Middleware> injected = ctx.processors().stream() if (ann == null)
.flatMap(p -> p.process(handler.getClass()).stream()) throw new InitializationException(
.toList(); handler.getClass().getName() + " is missing @Route");
Middleware[] all = Stream.concat( return track(new RouteHandle<>(this, mw ->
globalMiddlewares.stream(), deferredRoutes.add(new RouteDefinition(ann.method(), ann.path(), handler, NO_MW, mw, true, ctx, "/"))));
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 @Override
public FlashApp scan(String packageName) { public FlashApp scan(String packageName) {
flushPending(); flushPending();
PackageScanner.findHandlers(packageName).forEach(cls -> register(instantiate(cls)).ensureRegistered()); PackageScanner.findHandlers(packageName).forEach(cls ->
register(instantiate(cls)).ensureRegistered());
return this; return this;
} }
// ── Namespace mounting ─────────────────────────────────────────────────── // ── Namespace mounting (syntactic sugar — routes go into same flat router)
/** /**
* Mounts a scoped sub-router under {@code namespace}. The {@code configure} consumer * Mounts a scoped group of routes under {@code namespace}. The scope is a
* receives a {@link FlashScope} that has its own child {@link ExtensionContext} * pure builder — it prepends the namespace to each path and collects
* inheriting all parent services and annotation processors. * {@link RouteDefinition}s that merge into this app's single flat router.
*
* <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) { public FlashApp mount(String namespace, Consumer<FlashScope> configure) {
flushPending(); flushPending();
FlashScope scope = new FlashScope(new dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl(), FlashScope scope = new FlashScope(namespace, ctx);
namespace, ctx);
configure.accept(scope); configure.accept(scope);
scope.flush(); scope.flush();
router.mount(namespace, scope.router()); deferredRoutes.addAll(scope.routes());
return this; return this;
} }
// ── FlashRegistrar — error handlers ────────────────────────────────────── // ── Error handlers ────────────────────────────────────────────────────────
@Override
public FlashApp onException(AbstractRouter.ExceptionHandler handler) { public FlashApp onException(AbstractRouter.ExceptionHandler handler) {
flushPending(); flushPending();
router.onException(handler); router.onException(handler);
return this; return this;
} }
@Override
public FlashApp onNotFound(SimpleHandler.FunctionalHandler handler) { public FlashApp onNotFound(SimpleHandler.FunctionalHandler handler) {
flushPending(); flushPending();
router.onNotFound(handler); router.onNotFound(handler);
return this; return this;
} }
// ── FlashRegistrar — context ──────────────────────────────────────────────
@Override @Override
public ExtensionContext ctx() { public FlashContext ctx() { return ctx; }
return ctx;
}
// ── Lifecycle ───────────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────────
/** /**
* Flushes any pending route registration and starts the HTTP server. * Compiles all deferred routes into the flat FSM router, then starts
* * the HTTP transport. One pass, one router, zero prefix scanning.
* @return a future that completes once the accept loop is running
*/ */
public CompletableFuture<Void> start() { public CompletableFuture<Void> start() {
flushPending(); flushPending();
compile();
return server.start(); return server.start();
} }
/** Stops the HTTP server and closes all active connections. */ public CompletableFuture<Void> stop() { return server.stop(); }
public CompletableFuture<Void> stop() {
return server.stop(); // ── Compilation ──────────────────────────────────────────────────────────
/**
* Compiles all deferred routes. Middleware chain order:
* Global → Scope → Annotation (class-based only) → Explicit (.with).
*/
private void compile() {
for (RouteDefinition def : deferredRoutes) {
List<Middleware> injected;
if (def.classBasedHandler()) {
injected = def.ctx().processors().stream()
.flatMap(p -> p.process(def.handler().getClass()).stream())
.toList();
def.handler().bind(def.ctx());
} else {
injected = List.of();
}
Middleware[] all = concat(globalMiddlewares, def.scopeMiddlewares(),
injected, def.explicitMiddlewares());
emitEvent(def, all);
router.doRegister(def.method(), def.path(), def.handler(), all);
}
}
@SuppressWarnings("unchecked")
private void emitEvent(RouteDefinition def, Middleware[] allMiddlewares) {
List<RouteListener> listeners = def.ctx().routeListeners();
if (listeners.isEmpty()) return;
List<Class<? extends Middleware>> chain = new ArrayList<>(allMiddlewares.length);
for (Middleware m : allMiddlewares)
chain.add((Class<? extends Middleware>) m.getClass());
Class<?> handlerClass = def.classBasedHandler() ? def.handler().getClass() : null;
RouteEvent event = new RouteEvent(def.method(), def.path(), def.namespace(),
"FlashApp", handlerClass, List.copyOf(chain));
listeners.forEach(l -> l.onRoute(event));
} }
// ── Internals ───────────────────────────────────────────────────────────── // ── Internals ─────────────────────────────────────────────────────────────
@SuppressWarnings("unchecked") private static Middleware[] concat(List<Middleware> global, Middleware[] scope,
List<Middleware> injected, Middleware[] explicit) {
int total = global.size() + scope.length + injected.size() + explicit.length;
if (total == 0) return NO_MW;
if (total == explicit.length && scope.length == 0) return explicit;
Middleware[] all = new Middleware[total];
int i = 0;
for (Middleware m : global) all[i++] = m;
for (Middleware m : scope) all[i++] = m;
for (Middleware m : injected) all[i++] = m;
System.arraycopy(explicit, 0, all, i, explicit.length);
return all;
}
private static RequestHandler instantiate(Class<?> cls) { private static RequestHandler instantiate(Class<?> cls) {
try { try {
return (RequestHandler) cls.getDeclaredConstructor().newInstance(); return (RequestHandler) cls.getDeclaredConstructor().newInstance();
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException("Failed to instantiate handler: " + cls.getName() + throw new InitializationException(
"Failed to instantiate " + cls.getName() +
" — ensure it has a public no-arg constructor", e); " — 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));
}
} }
@@ -4,46 +4,42 @@ import java.util.*;
import java.util.stream.Stream; import java.util.stream.Stream;
/** /**
* Shared registry passed to every extension during {@link FlashExtension#install}. * Central service registry and boot-time hook coordinator.
* Extensions use it in two ways: *
* <p>Every handler, extension, and scope shares one (or a child of one) {@code FlashContext}.
* It provides three capabilities:
* <ol> * <ol>
* <li><b>Service sharing</b> provide/require typed objects (e.g. {@code ObjectMapper}, * <li><b>Service registry</b> typed {@link #provide}/{@link #require}/{@link #find}.</li>
* {@code OpenApiBuilder}) so extensions can build on each other.</li> * <li><b>Annotation processors</b> middleware injection from handler annotations.</li>
* <li><b>Annotation processing</b> register {@link AnnotationProcessor}s that * <li><b>Route listeners</b> boot-time observation of the route graph.</li>
* are invoked for every handler, injecting middleware derived from
* annotations ({@code @RolesAllowed}, {@code @Authenticated}, etc.).</li>
* </ol> * </ol>
* *
* <p>A child context (created via {@link #child()}) inherits all services and processors * <p>A child context (via {@link #child()}) inherits parent services and processors.
* from its parent. Services provided and processors added on the child are scoped to it * Services provided on the child are scoped and invisible to the parent.
* and not visible in the parent or sibling scopes.
*/ */
public class ExtensionContext { public class FlashContext {
private final ExtensionContext parent; private final FlashContext 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<>(); private final List<RouteListener> routeListeners = new ArrayList<>();
public ExtensionContext() { public FlashContext() {
this.parent = null; this.parent = null;
} }
private ExtensionContext(ExtensionContext parent) { private FlashContext(FlashContext parent) {
this.parent = parent; this.parent = parent;
} }
/** /** Creates a child context that inherits this context's services and processors. */
* Creates a child context that inherits this context's services and processors. public FlashContext child() {
* Services provided and processors added on the child do not affect the parent. return new FlashContext(this);
*/
public ExtensionContext child() {
return new ExtensionContext(this);
} }
// Service registry // Service registry
/** Stores {@code instance} under {@code type} for retrieval by other extensions. */ /** Stores {@code instance} under {@code type} for retrieval via {@link #require} or {@link #find}. */
public <T> void provide(Class<T> type, T instance) { public <T> void provide(Class<T> type, T instance) {
registry.put(type, instance); registry.put(type, instance);
} }
@@ -51,7 +47,8 @@ public class ExtensionContext {
/** /**
* Retrieves the service registered under {@code type}. * Retrieves the service registered under {@code type}.
* Checks own scope first, then the parent chain. * Checks own scope first, then the parent chain.
* Throws {@link IllegalStateException} if not found install order matters. *
* @throws IllegalStateException if not found
*/ */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public <T> T require(Class<T> type) { public <T> T require(Class<T> type) {
@@ -59,12 +56,12 @@ public class ExtensionContext {
if (val == null && parent != null) val = parent.find(type).orElse(null); 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() + "Service not found: " + type.getSimpleName() +
" — install the required extension first"); " provide it via FlashContext.provide() or install the required extension");
return val; return val;
} }
/** Returns the service under {@code type}, or empty if not installed in this scope or any parent. */ /** Returns the service under {@code type}, or empty if not provided 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) {
T val = (T) registry.get(type); T val = (T) registry.get(type);
@@ -72,21 +69,26 @@ public class ExtensionContext {
return parent != null ? parent.find(type) : Optional.empty(); return parent != null ? parent.find(type) : Optional.empty();
} }
/**
* Returns the service registered under {@code type} as an {@link Optional},
* or {@link Optional#empty()} if not present in this scope or any parent.
*
* <p>Semantically identical to {@link #find} prefer this name for expressive call sites
* ({@code ctx.optional(ViewEngine.class).ifPresent(...)}). Respects the parent-first
* scope hierarchy: own registry is checked first, then the parent chain.
*/
public <T> Optional<T> optional(Class<T> type) {
return find(type);
}
// Annotation processors // Annotation processors
/** /** Registers an {@link AnnotationProcessor}. Processors run once per class-based handler at boot. */
* Registers an {@link AnnotationProcessor}. Called by extensions during
* {@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);
} }
/** /** All processors visible from this context: parent-first, then own. */
* Returns all processors visible from this context: parent processors first,
* then processors added directly to this context.
*/
List<AnnotationProcessor> processors() { List<AnnotationProcessor> processors() {
if (parent == null) return Collections.unmodifiableList(processors); if (parent == null) return Collections.unmodifiableList(processors);
List<AnnotationProcessor> parentProcessors = parent.processors(); List<AnnotationProcessor> parentProcessors = parent.processors();
@@ -96,22 +98,12 @@ public class ExtensionContext {
// Route listeners // Route listeners
/** /** Registers a boot-time {@link RouteListener}. Zero overhead on the request hot-path. */
* 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) { public void addRouteListener(RouteListener listener) {
routeListeners.add(listener); routeListeners.add(listener);
} }
/** /** All route listeners visible from this context: parent-first, then own. */
* Returns all route listeners visible from this context: parent listeners first,
* then listeners added directly to this context.
*/
List<RouteListener> routeListeners() { List<RouteListener> routeListeners() {
if (parent == null) return Collections.unmodifiableList(routeListeners); if (parent == null) return Collections.unmodifiableList(routeListeners);
List<RouteListener> parentListeners = parent.routeListeners(); List<RouteListener> parentListeners = parent.routeListeners();
@@ -3,17 +3,17 @@ package dev.relism.extension;
/** /**
* Contract for all Flash extensions. An extension receives a {@link FlashRegistrar} * Contract for all Flash extensions. An extension receives a {@link FlashRegistrar}
* (either a {@link FlashApp} or a {@link FlashScope}) so it can register routes and * (either a {@link FlashApp} or a {@link FlashScope}) so it can register routes and
* expose shared services via {@link ExtensionContext}. * expose shared services via {@link FlashContext}.
* *
* <p>Extensions work identically whether installed at the top-level app or inside a * <p>Extensions work identically whether installed at the top-level app or inside a
* mounted scope: * mounted scope:
* *
* <pre>{@code * <pre>{@code
* public class RateLimitExtension implements FlashExtension { * public class RateLimitExtension implements FlashExtension {
* public void install(FlashRegistrar app, ExtensionContext ctx) { * public void install(FlashRegistrar app, FlashContext ctx) {
* RateLimiter limiter = new RateLimiter(100); * RateLimiter limiter = new RateLimiter(100);
* ctx.provide(RateLimiter.class, limiter); * ctx.provide(RateLimiter.class, limiter);
* app.onException((ex, req, res) -> { ... }); * app.get("/rate-info", (req, res) -> limiter.info());
* } * }
* } * }
* *
@@ -28,5 +28,5 @@ package dev.relism.extension;
*/ */
@FunctionalInterface @FunctionalInterface
public interface FlashExtension { public interface FlashExtension {
void install(FlashRegistrar app, ExtensionContext ctx); void install(FlashRegistrar app, FlashContext ctx);
} }
@@ -1,23 +1,17 @@
package dev.relism.extension; package dev.relism.extension;
import dev.relism.models.RequestHandler;
import dev.relism.models.SimpleHandler; import dev.relism.models.SimpleHandler;
import dev.relism.routing.AbstractRouter;
import dev.relism.routing.RouteHandle; import dev.relism.routing.RouteHandle;
/** /**
* Common registration surface shared by {@link FlashApp} and {@link FlashScope}. * Common registration surface shared by {@link FlashApp} and {@link FlashScope}.
* *
* <p>{@link FlashExtension#install} receives a {@code FlashRegistrar} so that extensions * <p>{@link FlashExtension#install} receives a {@code FlashRegistrar} so extensions
* work identically whether installed at the top-level app or inside a mounted scope. * 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 * <pre>{@code
* // In an extension: * // In an extension:
* public void install(FlashRegistrar app, ExtensionContext ctx) { * public void install(FlashRegistrar app, FlashContext ctx) {
* app.get("/health", (req, res) -> "ok"); * app.get("/health", (req, res) -> "ok");
* app.get("/secured", (req, res) -> user()).with(oidc.protect()); * app.get("/secured", (req, res) -> user()).with(oidc.protect());
* } * }
@@ -25,11 +19,9 @@ import dev.relism.routing.RouteHandle;
*/ */
public interface FlashRegistrar { public interface FlashRegistrar {
// ── Extension installation ────────────────────────────────────────────────
FlashRegistrar install(FlashExtension ext); FlashRegistrar install(FlashExtension ext);
// ── Route registration ──────────────────────────────────────────────────── // ── Lambda route registration ────────────────────────────────────────────
RouteHandle<?> get (String path, SimpleHandler.FunctionalHandler h); RouteHandle<?> get (String path, SimpleHandler.FunctionalHandler h);
RouteHandle<?> post (String path, SimpleHandler.FunctionalHandler h); RouteHandle<?> post (String path, SimpleHandler.FunctionalHandler h);
@@ -43,29 +35,13 @@ public interface FlashRegistrar {
RouteHandle<?> purge (String path, SimpleHandler.FunctionalHandler h); RouteHandle<?> purge (String path, SimpleHandler.FunctionalHandler h);
/** /**
* Begins registration of a class-based handler. The class must carry a * Scans {@code packageName} for classes that extend
* {@link dev.relism.routing.Route @Route} annotation. Annotation processors * {@link dev.relism.models.RequestHandler} and carry
* (e.g. {@code @Authenticated}, {@code @RolesAllowed}) are applied automatically. * {@link dev.relism.routing.Route @Route}. Each is instantiated via its
*
* <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. * no-arg constructor, run through annotation processors, and registered.
*/ */
FlashRegistrar scan(String packageName); FlashRegistrar scan(String packageName);
// ── Error handlers ──────────────────────────────────────────────────────── /** Returns the {@link FlashContext} for this registrar. */
FlashContext ctx();
FlashRegistrar onException(AbstractRouter.ExceptionHandler h);
FlashRegistrar onNotFound(SimpleHandler.FunctionalHandler h);
// ── Context access ────────────────────────────────────────────────────────
/** Returns the {@link ExtensionContext} for this registrar (app or scope). */
ExtensionContext ctx();
} }
@@ -1,62 +1,43 @@
package dev.relism.extension; package dev.relism.extension;
import dev.relism.exceptions.InitializationException;
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.Middleware;
import dev.relism.routing.PathUtils; import dev.relism.routing.PathUtils;
import dev.relism.routing.Route; import dev.relism.routing.Route;
import dev.relism.routing.RouteHandle; 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.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Enumeration;
import java.util.List; 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. * Scoped builder for namespace-prefixed routes. Pure syntactic sugar —
* * does not own a router. Collected routes merge into {@link FlashApp}'s
* <p>Obtained via {@link FlashApp#mount(String, java.util.function.Consumer)}. * single flat router at {@link FlashApp#start()}.
* 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 * <pre>{@code
* app.mount("/api", scope -> { * app.mount("/api", scope -> {
* scope.install(new RateLimitExtension()); * scope.use(authMiddleware);
* scope.register(new UserHandler()); // @Authenticated auto-injected * scope.get("/health", (req, res) -> "ok"); // → GET /api/health
* scope.get("/health", (req, res) -> "ok");
* scope.scan("dev.example.api.handlers"); * scope.scan("dev.example.api.handlers");
* }); * });
* }</pre> * }</pre>
*/ */
public final class FlashScope implements FlashRegistrar { public final class FlashScope implements FlashRegistrar {
private final AbstractRouter router; private final String namespace;
private final String namespace; private final FlashContext ctx;
private final ExtensionContext ctx; private final List<Middleware> scopeMiddlewares = new ArrayList<>();
private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
private static final Middleware[] NO_MW = new Middleware[0];
/** Pending RouteHandle awaiting .with() — auto-flushed before each new registration. */
private RouteHandle<?> pending; private RouteHandle<?> pending;
/** FlashScope(String namespace, FlashContext parentCtx) {
* 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.namespace = namespace;
this.ctx = parentCtx.child(); this.ctx = parentCtx.child();
} }
@@ -64,10 +45,7 @@ public final class FlashScope implements FlashRegistrar {
// ── Pending flush ───────────────────────────────────────────────────────── // ── Pending flush ─────────────────────────────────────────────────────────
private void flushPending() { private void flushPending() {
if (pending != null) { if (pending != null) { pending.ensureRegistered(); pending = null; }
pending.ensureRegistered();
pending = null;
}
} }
private <P> RouteHandle<P> track(RouteHandle<P> handle) { private <P> RouteHandle<P> track(RouteHandle<P> handle) {
@@ -76,12 +54,20 @@ public final class FlashScope implements FlashRegistrar {
return handle; return handle;
} }
// ── FlashRegistrar — extension installation ─────────────────────────────── // ── Scope middleware ──────────────────────────────────────────────────────
/** /**
* Installs an extension scoped to this namespace. * Adds middlewares applied to every route in this scope.
* The extension registers routes and services on this scope only. * Combined at compile time in order: Global → Scope → Annotation → Explicit.
*/ */
public FlashScope use(Middleware... middlewares) {
flushPending();
scopeMiddlewares.addAll(Arrays.asList(middlewares));
return this;
}
// ── Extension installation ────────────────────────────────────────────────
@Override @Override
public FlashScope install(FlashExtension ext) { public FlashScope install(FlashExtension ext) {
flushPending(); flushPending();
@@ -89,128 +75,73 @@ public final class FlashScope implements FlashRegistrar {
return this; return this;
} }
// ── FlashRegistrar — route registration ─────────────────────────────────── // ── Route registration (deferred) ─────────────────────────────────────────
@Override public RouteHandle<FlashScope> get (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.GET, path, h); } @Override public RouteHandle<FlashScope> get (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.GET, path, h); }
@Override public RouteHandle<FlashScope> post (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.POST, path, h); } @Override public RouteHandle<FlashScope> post (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.POST, path, h); }
@Override public RouteHandle<FlashScope> put (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PUT, path, h); } @Override public RouteHandle<FlashScope> put (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.PUT, path, h); }
@Override public RouteHandle<FlashScope> delete (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.DELETE, path, h); } @Override public RouteHandle<FlashScope> delete (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.DELETE, path, h); }
@Override public RouteHandle<FlashScope> patch (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PATCH, path, h); } @Override public RouteHandle<FlashScope> patch (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.PATCH, path, h); }
@Override public RouteHandle<FlashScope> options(String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.OPTIONS, path, h); } @Override public RouteHandle<FlashScope> options(String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.OPTIONS, path, h); }
@Override public RouteHandle<FlashScope> head (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.HEAD, path, h); } @Override public RouteHandle<FlashScope> head (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.HEAD, path, h); }
@Override public RouteHandle<FlashScope> trace (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.TRACE, path, h); } @Override public RouteHandle<FlashScope> trace (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.TRACE, path, h); }
@Override public RouteHandle<FlashScope> connect(String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.CONNECT, path, h); } @Override public RouteHandle<FlashScope> connect(String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.CONNECT, path, h); }
@Override public RouteHandle<FlashScope> purge (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PURGE, path, h); } @Override public RouteHandle<FlashScope> purge (String path, SimpleHandler.FunctionalHandler h) { return lambda(HttpMethod.PURGE, path, h); }
private RouteHandle<FlashScope> routeLambda(HttpMethod method, String path, SimpleHandler.FunctionalHandler h) { private RouteHandle<FlashScope> lambda(HttpMethod method, String path, SimpleHandler.FunctionalHandler h) {
return track(new RouteHandle<>(this, m -> { String full = ns(path);
String full = ns(path); Middleware[] scopeMw = snapshotScopeMiddlewares();
emit(method, full, null, List.of(), m); return track(new RouteHandle<>(this, mw ->
router.doRegister(method, full, h, m); deferredRoutes.add(new RouteDefinition(method, full, new SimpleHandler(h), scopeMw, mw, false, ctx, namespace))));
}));
} }
/** /**
* Begins registration of a class-based handler. Annotation processors from the * Registers a class-based handler annotated with {@link Route @Route}.
* parent app and any installed on this scope are applied. The {@link Route @Route} * Not part of {@link FlashRegistrar} — scope-only convenience.
* 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 RouteHandle<FlashScope> register(RequestHandler handler) {
public RouteHandle<FlashScope> register(RequestHandler handler) { Route ann = handler.getClass().getAnnotation(Route.class);
return track(new RouteHandle<>(this, explicit -> { if (ann == null)
List<Middleware> injected = ctx.processors().stream() throw new InitializationException(
.flatMap(p -> p.process(handler.getClass()).stream()) handler.getClass().getName() + " is missing @Route");
.toList(); String full = ns(ann.path());
Middleware[] all = injected.isEmpty() Middleware[] scopeMw = snapshotScopeMiddlewares();
? explicit return track(new RouteHandle<>(this, mw ->
: Stream.concat(injected.stream(), Arrays.stream(explicit)).toArray(Middleware[]::new); deferredRoutes.add(new RouteDefinition(ann.method(), full, handler, scopeMw, mw, true, ctx, namespace))));
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 @Override
public FlashScope scan(String packageName) { public FlashScope scan(String packageName) {
flushPending(); flushPending();
PackageScanner.findHandlers(packageName).forEach(cls -> register(instantiate(cls)).ensureRegistered()); PackageScanner.findHandlers(packageName).forEach(cls ->
register(instantiate(cls)).ensureRegistered());
return this; return this;
} }
@Override @Override
public FlashScope onException(AbstractRouter.ExceptionHandler h) { public FlashContext ctx() { return ctx; }
flushPending();
router.onException(h);
return this;
}
@Override // ── Internals (called by FlashApp.mount) ──────────────────────────────────
public FlashScope onNotFound(SimpleHandler.FunctionalHandler h) {
flushPending();
router.onNotFound(h);
return this;
}
@Override void flush() { flushPending(); }
public ExtensionContext ctx() {
return ctx;
}
// ── Internals ───────────────────────────────────────────────────────────── List<RouteDefinition> routes() { return deferredRoutes; }
/** 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) { private String ns(String path) {
return namespace + PathUtils.sanitize(path); return namespace + PathUtils.sanitize(path);
} }
@SuppressWarnings("unchecked") private Middleware[] snapshotScopeMiddlewares() {
return scopeMiddlewares.isEmpty() ? NO_MW : scopeMiddlewares.toArray(NO_MW);
}
private static RequestHandler instantiate(Class<?> cls) { private static RequestHandler instantiate(Class<?> cls) {
try { try {
return (RequestHandler) cls.getDeclaredConstructor().newInstance(); return (RequestHandler) cls.getDeclaredConstructor().newInstance();
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException("Failed to instantiate handler: " + cls.getName() + throw new InitializationException(
"Failed to instantiate " + cls.getName() +
" — ensure it has a public no-arg constructor", e); " — 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));
}
} }
@@ -1,5 +1,6 @@
package dev.relism.extension; package dev.relism.extension;
import dev.relism.exceptions.InitializationException;
import dev.relism.models.RequestHandler; import dev.relism.models.RequestHandler;
import dev.relism.routing.Route; import dev.relism.routing.Route;
@@ -15,6 +16,10 @@ import java.util.jar.JarFile;
* Minimal classpath scanner used by {@link FlashApp#scan} and {@link FlashScope#scan}. * 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}. * Finds all classes in a package that extend {@link RequestHandler} and carry {@link Route @Route}.
* Supports both exploded directories (development) and fat JARs (deployment). * Supports both exploded directories (development) and fat JARs (deployment).
*
* <p><b>Fail-fast:</b> if the package does not exist, contains no handlers, or a handler
* class cannot be loaded, an {@link InitializationException} is thrown immediately.
* A clear crash at boot is always preferable to a server that starts "empty".
*/ */
final class PackageScanner { final class PackageScanner {
@@ -22,55 +27,84 @@ final class PackageScanner {
/** /**
* Returns all {@link RequestHandler} subclasses in {@code packageName} that carry * Returns all {@link RequestHandler} subclasses in {@code packageName} that carry
* {@link Route @Route} and have a public no-arg constructor. * {@link Route @Route}.
*
* @throws InitializationException if the package is empty, does not exist, or a
* handler class fails to load
*/ */
static List<Class<?>> findHandlers(String packageName) { static List<Class<?>> findHandlers(String packageName) {
if (packageName == null || packageName.isBlank())
throw new InitializationException("scan() called with null or blank package name");
String resourcePath = packageName.replace('.', '/'); String resourcePath = packageName.replace('.', '/');
ClassLoader cl = Thread.currentThread().getContextClassLoader(); ClassLoader cl = Thread.currentThread().getContextClassLoader();
List<Class<?>> result = new ArrayList<>(); List<Class<?>> result = new ArrayList<>();
List<String> errors = new ArrayList<>();
boolean packageFound = false;
try { try {
Enumeration<URL> resources = cl.getResources(resourcePath); Enumeration<URL> resources = cl.getResources(resourcePath);
while (resources.hasMoreElements()) { while (resources.hasMoreElements()) {
packageFound = true;
URL url = resources.nextElement(); URL url = resources.nextElement();
String protocol = url.getProtocol(); String protocol = url.getProtocol();
if ("file".equals(protocol)) { if ("file".equals(protocol)) {
scanDirectory(new File(url.toURI()), packageName, cl, result); scanDirectory(new File(url.toURI()), packageName, cl, result, errors);
} else if ("jar".equals(protocol)) { } else if ("jar".equals(protocol)) {
String jarPath = url.getPath(); 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('!')); String filePart = jarPath.substring(jarPath.indexOf("file:") + 5, jarPath.indexOf('!'));
try (JarFile jar = new JarFile(filePart)) { try (JarFile jar = new JarFile(filePart)) {
scanJar(jar, resourcePath, packageName, cl, result); scanJar(jar, resourcePath, packageName, cl, result, errors);
} }
} }
} }
} catch (InitializationException e) {
throw e; // re-throw our own exceptions
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException("Failed to scan package: " + packageName, e); throw new InitializationException("Failed to scan package: " + packageName, e);
} }
if (!packageFound)
throw new InitializationException(
"scan(\"" + packageName + "\") — package not found on classpath. " +
"Verify the package name and ensure the module is on the classpath.");
if (!errors.isEmpty())
throw new InitializationException(
"scan(\"" + packageName + "\") — failed to load " + errors.size() + " handler(s):\n • " +
String.join("\n • ", errors));
if (result.isEmpty())
throw new InitializationException(
"scan(\"" + packageName + "\") — no @Route handlers found. " +
"Ensure handler classes extend RequestHandler, carry @Route, are not abstract, " +
"and have a public no-arg constructor.");
return result; return result;
} }
private static void scanDirectory(File dir, String packageName, ClassLoader cl, List<Class<?>> result) { private static void scanDirectory(File dir, String packageName, ClassLoader cl,
List<Class<?>> result, List<String> errors) {
File[] files = dir.listFiles(); File[] files = dir.listFiles();
if (files == null) return; if (files == null) return;
for (File file : files) { for (File file : files) {
if (file.isDirectory()) { if (file.isDirectory()) {
scanDirectory(file, packageName + '.' + file.getName(), cl, result); scanDirectory(file, packageName + '.' + file.getName(), cl, result, errors);
} else if (file.getName().endsWith(".class") && !isAnonymous(file.getName())) { } else if (file.getName().endsWith(".class") && !isAnonymous(file.getName())) {
String className = packageName + '.' + file.getName().replace(".class", ""); String className = packageName + '.' + file.getName().replace(".class", "");
tryLoad(className, cl, result); tryLoad(className, cl, result, errors);
} }
} }
} }
private static void scanJar(JarFile jar, String resourcePath, String packageName, private static void scanJar(JarFile jar, String resourcePath, String packageName,
ClassLoader cl, List<Class<?>> result) { ClassLoader cl, List<Class<?>> result, List<String> errors) {
Enumeration<JarEntry> entries = jar.entries(); Enumeration<JarEntry> entries = jar.entries();
while (entries.hasMoreElements()) { while (entries.hasMoreElements()) {
String name = entries.nextElement().getName(); String name = entries.nextElement().getName();
if (name.startsWith(resourcePath) && name.endsWith(".class") && !isAnonymous(name)) { if (name.startsWith(resourcePath) && name.endsWith(".class") && !isAnonymous(name)) {
String className = name.replace('/', '.').replace(".class", ""); String className = name.replace('/', '.').replace(".class", "");
tryLoad(className, cl, result); tryLoad(className, cl, result, errors);
} }
} }
} }
@@ -84,23 +118,34 @@ final class PackageScanner {
private static boolean isAnonymous(String fileName) { private static boolean isAnonymous(String fileName) {
int dollar = fileName.lastIndexOf('$'); int dollar = fileName.lastIndexOf('$');
if (dollar < 0) return false; if (dollar < 0) return false;
// skip past any extra '$' (lambda desugaring may produce '$$Lambda$...')
int next = dollar + 1; int next = dollar + 1;
while (next < fileName.length() && fileName.charAt(next) == '$') next++; while (next < fileName.length() && fileName.charAt(next) == '$') next++;
return next < fileName.length() && Character.isDigit(fileName.charAt(next)); return next < fileName.length() && Character.isDigit(fileName.charAt(next));
} }
private static void tryLoad(String className, ClassLoader cl, List<Class<?>> result) { private static void tryLoad(String className, ClassLoader cl,
List<Class<?>> result, List<String> errors) {
try { try {
Class<?> cls = cl.loadClass(className); Class<?> cls = cl.loadClass(className);
if (RequestHandler.class.isAssignableFrom(cls) if (!RequestHandler.class.isAssignableFrom(cls)) return;
&& cls.isAnnotationPresent(Route.class) if (java.lang.reflect.Modifier.isAbstract(cls.getModifiers())) return;
&& !java.lang.reflect.Modifier.isAbstract(cls.getModifiers())) { if (!cls.isAnnotationPresent(Route.class)) return;
cls.getDeclaredConstructor(); // verify no-arg constructor exists
result.add(cls); // Verify no-arg constructor exists — fail-fast if missing
try {
cls.getDeclaredConstructor();
} catch (NoSuchMethodException e) {
errors.add(className + " — missing public no-arg constructor");
return;
} }
} catch (Exception | Error ignored) {
// Skip classes that cannot be loaded or don't meet criteria result.add(cls);
} catch (ClassNotFoundException e) {
errors.add(className + " — class not found: " + e.getMessage());
} catch (NoClassDefFoundError e) {
errors.add(className + " — missing dependency: " + e.getMessage());
} catch (LinkageError e) {
errors.add(className + " — linkage error: " + e.getMessage());
} }
} }
} }
@@ -0,0 +1,32 @@
package dev.relism.extension;
import dev.relism.http.HttpMethod;
import dev.relism.models.RequestHandler;
import dev.relism.routing.Middleware;
/**
* Immutable snapshot of a route captured during the builder phase.
*
* <p>Accumulated by {@link FlashApp} and {@link FlashScope}. All definitions —
* regardless of origin — are compiled into a single flat router at
* {@link FlashApp#start()}.
*
* @param method HTTP method
* @param path fully resolved path (namespace already prepended for scope routes)
* @param handler SimpleHandler for lambdas, user handler for class-based
* @param scopeMiddlewares middlewares from {@link FlashScope#use} (empty for app-level routes)
* @param explicitMiddlewares middlewares from {@link dev.relism.routing.RouteHandle#with}
* @param classBasedHandler true → needs annotation processing + context binding
* @param ctx the FlashContext for this route's binding and processors
* @param namespace logical namespace for route events ("/" for app, "/api" for scope, etc.)
*/
record RouteDefinition(
HttpMethod method,
String path,
RequestHandler handler,
Middleware[] scopeMiddlewares,
Middleware[] explicitMiddlewares,
boolean classBasedHandler,
FlashContext ctx,
String namespace
) {}
@@ -3,7 +3,7 @@ package dev.relism.extension;
/** /**
* Observer notified once for each route registered on a {@link FlashApp} or {@link FlashScope}. * Observer notified once for each route registered on a {@link FlashApp} or {@link FlashScope}.
* *
* <p>Register via {@link ExtensionContext#addRouteListener}. The listener is called * <p>Register via {@link FlashContext#addRouteListener}. The listener is called
* <em>once per route at boot time</em>, before the route is handed to the routing engine. * <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. * There is zero overhead on the request hot-path.
* *
@@ -10,6 +10,7 @@ import lombok.Value;
import lombok.experimental.NonFinal; import lombok.experimental.NonFinal;
import java.io.InputStream; import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
@@ -43,24 +44,42 @@ public class Request {
@NonFinal @Setter PathParams pathParams; @NonFinal @Setter PathParams pathParams;
@NonFinal @Setter QueryParams queryParams; @NonFinal @Setter QueryParams queryParams;
private Request(RequestLine requestLine, RequestBody body) { /**
this.requestLine = requestLine; * Remote socket address of the connected client. Set once at connection time from
this.body = body; * {@link java.net.Socket#getRemoteSocketAddress()} — the {@link InetSocketAddress}
this.pathParams = null; * object already exists in the JDK and is passed by reference: zero allocation,
this.queryParams = null; * zero copy. {@code null} only in test-constructed requests.
*
* <p>Use {@link #remoteAddress()} to access it. String conversion
* ({@code .getAddress().getHostAddress()}) is deferred to the caller — lazy and
* only paid when actually needed.
*/
@Getter(lombok.AccessLevel.NONE)
@EqualsAndHashCode.Exclude
@ToString.Exclude
InetSocketAddress remoteAddress;
private Request(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress) {
this.requestLine = requestLine;
this.body = body;
this.pathParams = null;
this.queryParams = null;
this.remoteAddress = remoteAddress;
} }
/** Test / manual constructor — {@code remoteAddress()} returns {@code null}. */
public Request(RequestLine requestLine, byte[] body) { public Request(RequestLine requestLine, byte[] body) {
this(requestLine, RequestBody.of(body)); this(requestLine, RequestBody.of(body), null);
} }
public static Request forParsed(RequestLine requestLine, InputStream stream, public static Request forParsed(RequestLine requestLine, InputStream stream,
long contentLength, byte[] headerBuf, long contentLength, byte[] headerBuf,
int bodyStart, int preBufLen) { int bodyStart, int preBufLen,
InetSocketAddress remoteAddress) {
RequestBody rb = contentLength > 0 ? new RequestBody(stream, contentLength, headerBuf, bodyStart, preBufLen) RequestBody rb = contentLength > 0 ? new RequestBody(stream, contentLength, headerBuf, bodyStart, preBufLen)
: contentLength == 0 ? RequestBody.empty() : contentLength == 0 ? RequestBody.empty()
: /* chunked */ new RequestBody(stream, -1L, null, 0, 0); : /* chunked */ new RequestBody(stream, -1L, null, 0, 0);
return new Request(requestLine, rb); return new Request(requestLine, rb, remoteAddress);
} }
// ── Request line ────────────────────────────────────────────────────────── // ── Request line ──────────────────────────────────────────────────────────
@@ -126,6 +145,22 @@ public class Request {
*/ */
public List<String> queries(String name) { return resolveQueryParams().getAll(name); } public List<String> queries(String name) { return resolveQueryParams().getAll(name); }
// ── Remote address ────────────────────────────────────────────────────────
/**
* Returns the remote socket address of the connected client, or {@code null}
* for test-constructed requests.
*
* <p>The {@link InetSocketAddress} is the JDK object created during
* {@link java.net.ServerSocket#accept()} — no allocation occurs here.
* To obtain the IP string (lazy, allocates once):
* <pre>{@code
* InetSocketAddress addr = req.remoteAddress();
* if (addr != null) String ip = addr.getAddress().getHostAddress();
* }</pre>
*/
public InetSocketAddress remoteAddress() { return remoteAddress; }
// ── Body ────────────────────────────────────────────────────────────────── // ── Body ──────────────────────────────────────────────────────────────────
/** /**
@@ -1,17 +1,134 @@
package dev.relism.models; package dev.relism.models;
import dev.relism.extension.FlashContext;
import java.util.Optional;
/** /**
* Base class for class-based route handlers. * Base class for class-based route handlers.
* *
* <p>Annotate the subclass with {@link dev.relism.routing.Route @Route} and register it * <p>Annotate the subclass with {@link dev.relism.routing.Route @Route} and register it
* via {@link dev.relism.routing.AbstractRouter#register}. For one-off routes, prefer the * via {@link dev.relism.extension.FlashApp#register} or {@link dev.relism.extension.FlashApp#scan}.
* lambda DSL ({@code server.get(path, handler)}) which wraps a {@link SimpleHandler} internally. * For one-off routes, prefer the lambda DSL ({@code app.get(path, handler)}).
*
* <h3>Lifecycle</h3>
* <ol>
* <li>Instantiation — no-arg constructor (for scan) or manual {@code new Handler(...)}</li>
* <li>{@link #bind} — called once by the framework at {@code start()}, injects the
* {@link FlashContext} and invokes {@link #onInit()}</li>
* <li>{@link #handle} — called on every matching request (hot-path, zero-alloc)</li>
* </ol>
*
* <h3>Service access</h3>
* Override {@link #onInit()} to cache services from the {@link FlashContext}
* into private fields. This keeps the hot-path ({@code handle}) free of map lookups.
*
* <pre>{@code
* @Route(method = HttpMethod.GET, path = "/users")
* public class UserHandler extends RequestHandler {
* private UserService users;
*
* @Override protected void onInit() {
* users = require(UserService.class);
* }
*
* @Override public Object handle(Request req, Response res) {
* return users.findAll();
* }
* }
* }</pre>
*/ */
public abstract class RequestHandler { public abstract class RequestHandler {
private FlashContext ctx;
/**
* Called once by the framework after instantiation, before the first request.
* Injects the {@link FlashContext} and triggers {@link #onInit()}.
*
* <p><b>Infrastructure method</b> — do not call from user code.
* Use {@link dev.relism.extension.FlashApp#register} or
* {@link dev.relism.extension.FlashApp#scan} instead.
*/
public final void bind(FlashContext ctx) {
this.ctx = ctx;
onInit();
}
/**
* Override to cache services at boot time. Called once after {@link #bind},
* before any request reaches this handler.
*
* <p>Use {@link #require} and {@link #find} to retrieve services from the
* {@link FlashContext}. Cache them in private fields so the hot-path
* ({@link #handle}) has zero lookup overhead.
*
* <p><b>Important:</b> if your class extends another handler base (e.g.
* {@code JacksonHandler}), call {@code super.onInit()} first so the parent
* can initialise its own services.
*
* <pre>{@code
* @Override protected void onInit() {
* super.onInit();
* myService = require(MyService.class);
* }
* }</pre>
*/
protected void onInit() {}
/**
* Retrieves a required service from the {@link FlashContext}.
* Throws {@link IllegalStateException} if the service is not registered or
* this handler has not been bound yet.
*
* <p>Typically called inside {@link #onInit()} to cache the result.
*
* @param type the service class
* @param <T> the service type
* @return the service instance, never null
*/
protected <T> T require(Class<T> type) {
checkBound();
return ctx.require(type);
}
/**
* Looks up an optional service from the {@link FlashContext}.
*
* @param type the service class
* @param <T> the service type
* @return the service, or empty if not registered
*/
protected <T> Optional<T> find(Class<T> type) {
checkBound();
return ctx.find(type);
}
/**
* Looks up an optional service from the {@link FlashContext}.
* Identical to {@link #find} — prefer this name for expressive call sites
* ({@code optional(ViewEngine.class).ifPresent(...)}).
*
* @param type the service class
* @param <T> the service type
* @return the service, or empty if not registered
*/
protected <T> Optional<T> optional(Class<T> type) {
checkBound();
return ctx.optional(type);
}
private void checkBound() {
if (ctx == null)
throw new IllegalStateException(
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
"register via FlashApp.register() or FlashApp.scan(), not directly on the router");
}
/** /**
* Handles an incoming request. The return value determines the response body: * Handles an incoming request. The return value determines the response body:
* return a {@link Response} to replace the whole response, any other non-null value * return a {@link Response} to replace the whole response, any other non-null value
* to set it as the body, or {@code null} to leave the response as-is. * to set it as the body, or {@code null} to leave the response as-is.
*/ */
public abstract Object handle(Request request, Response response) throws Exception; public abstract Object handle(Request request, Response response) throws Exception;
} }
@@ -1,53 +1,24 @@
package dev.relism.routing; package dev.relism.routing;
import dev.relism.fpr.core.ByteView;
import dev.relism.http.ContentType; import dev.relism.http.ContentType;
import dev.relism.http.HttpMethod; import dev.relism.http.HttpMethod;
import dev.relism.fpr.core.ByteView;
import dev.relism.models.*; import dev.relism.models.*;
import dev.relism.template.ErrorPages; import dev.relism.template.ErrorPages;
import lombok.AccessLevel;
import lombok.Getter;
import java.nio.charset.StandardCharsets;
/** /**
* Base router. Each router has a namespace prefix (default {@code "/"}). * Base router contract. Owns error handlers and the compile-time middleware
* Error handlers are scoped to this router. * wrapping logic. The only concrete implementation is
* {@link dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl}.
* *
* <p>Router-level {@link Middleware middlewares} are passed at construction time and * <p>All middleware composition happens once at boot time; the hot-path sees
* pre-fused into a single wrapper applied to every handler registered on this router. * only a plain {@link RequestHandler} call with zero allocation overhead.
* Handler-level middlewares are passed to {@link #doRegister} and wrapped
* <em>inside</em> the router-level chain.
* *
* <p>All middleware composition happens once at boot time; the hot-path sees only a plain * <p><b>Registration</b>: use {@link dev.relism.extension.FlashApp} — the single
* {@link RequestHandler} call with zero allocation and zero lookup overhead. * public registration API. {@link #doRegister} is an infrastructure method.
*
* <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 {
@Getter
protected String namespace = "/";
@Getter(AccessLevel.PACKAGE)
protected byte[] namespaceBytes = new byte[]{ '/' };
/**
* Pre-fused router-level middleware, or {@code null} when none were registered.
* A single null-check in {@link #compile} is the only cost when no router middleware exists.
*/
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);
@@ -60,113 +31,44 @@ public abstract class AbstractRouter {
return ErrorPages.renderException(req, ex); return ErrorPages.renderException(req, ex);
}; };
/** public SimpleHandler getNotFoundHandler() { return notFoundHandler; }
* Constructs a router with optional router-level middlewares. public ExceptionHandler getExceptionHandler() { return exceptionHandler; }
* Middlewares are pre-fused at construction time; the first element executes outermost.
*
* @param middlewares zero or more middlewares applied to every handler on this router
*/
protected AbstractRouter(Middleware... middlewares) {
this.rawRouterMiddlewares = middlewares;
this.routerMiddleware = middlewares.length == 0 ? null
: middlewares.length == 1 ? middlewares[0]
: Middleware.of(middlewares);
}
/** public void onNotFound(SimpleHandler.FunctionalHandler handler) {
* 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 ───────────────────────────────────────────────────────
SimpleHandler getNotFoundHandler() { return notFoundHandler; }
ExceptionHandler getExceptionHandler() { return exceptionHandler; }
void setNamespace(String namespace) {
this.namespace = namespace;
this.namespaceBytes = namespace.getBytes(StandardCharsets.UTF_8);
}
/**
* Compiles a handler with its middleware chain. Called once per handler at registration.
*
* <p>Application order (innermost → outermost):
* <ol>
* <li>handler-level middlewares (passed at the call site)</li>
* <li>router-level middleware (set in the constructor)</li>
* </ol>
*/
private RequestHandler compile(RequestHandler handler, Middleware[] handlerMiddlewares) {
RequestHandler compiled = handler;
for (int i = handlerMiddlewares.length - 1; i >= 0; i--)
compiled = new SimpleHandler(handlerMiddlewares[i].wrap(compiled));
if (routerMiddleware != null)
compiled = new SimpleHandler(routerMiddleware.wrap(compiled));
return compiled;
}
// ── Error handler configuration ───────────────────────────────────────────
public AbstractRouter onNotFound(SimpleHandler.FunctionalHandler handler) {
this.notFoundHandler = new SimpleHandler(handler); this.notFoundHandler = new SimpleHandler(handler);
return this;
} }
public AbstractRouter onException(ExceptionHandler handler) { public void onException(ExceptionHandler handler) {
this.exceptionHandler = handler; this.exceptionHandler = handler;
return this;
} }
// ── Infrastructure registration ────────────────────────────────────────── // ── Infrastructure registration ──────────────────────────────────────────
// Used by FlashApp and FlashScope. Not part of the public user-facing API. // Used by FlashApp at compile time. Not part of the user-facing API.
/** /**
* Registers a lambda handler immediately with a pre-built middleware array. * Registers a handler with a pre-built middleware array.
* Infrastructure method — use {@link dev.relism.extension.FlashApp} instead. * Compiles the middleware chain once at boot — zero overhead on hot-path.
*/
public AbstractRouter doRegister(HttpMethod method, String path,
SimpleHandler.FunctionalHandler handler, Middleware[] middlewares) {
return addRoute(method, PathUtils.sanitize(path),
compile(new SimpleHandler(handler), middlewares));
}
/**
* Registers a class-based handler immediately with a pre-built middleware array.
* Reads {@link Route @Route} for method and path.
* Infrastructure method — use {@link dev.relism.extension.FlashApp} instead.
*/
public AbstractRouter doRegister(RequestHandler handler, Middleware[] middlewares) {
Route annotation = handler.getClass().getAnnotation(Route.class);
if (annotation != null)
addRoute(annotation.method(), annotation.path(),
compile(handler, middlewares));
return this;
}
/**
* Registers a class-based handler with an explicit method and path (ignoring the
* {@link Route @Route} annotation's path). Used by {@link dev.relism.extension.FlashScope}
* to prepend the scope's namespace prefix.
* Infrastructure method — use {@link dev.relism.extension.FlashScope} instead.
*/ */
public AbstractRouter doRegister(HttpMethod method, String path, public AbstractRouter doRegister(HttpMethod method, String path,
RequestHandler handler, Middleware[] middlewares) { RequestHandler handler, Middleware[] middlewares) {
return addRoute(method, PathUtils.sanitize(path), compile(handler, middlewares)); return addRoute(method, PathUtils.sanitize(path), compile(handler, middlewares));
} }
// ── Routing ─────────────────────────────────────────────────────────────── /**
* Compiles handler-level middleware into a single wrapped handler.
* Called once per route at registration time.
*/
private RequestHandler compile(RequestHandler handler, Middleware[] middlewares) {
RequestHandler compiled = handler;
for (int i = middlewares.length - 1; i >= 0; i--)
compiled = new SimpleHandler(middlewares[i].wrap(compiled));
return compiled;
}
// ── Routing ──────────────────────────────────────────────────────────────
public abstract RequestHandler route(Request request); public abstract RequestHandler route(Request request);
/**
* Stores a pre-compiled handler in the underlying routing structure.
* The handler passed here is already fully wrapped — implementations must not
* apply any additional middleware logic.
*/
protected abstract AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler); protected abstract AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler);
protected static void setPathParams(Request request, String[] names, ByteView source, int[] starts, int[] lens) { protected static void setPathParams(Request request, String[] names, ByteView source, int[] starts, int[] lens) {
@@ -1,75 +0,0 @@
package dev.relism.routing;
import dev.relism.exceptions.DuplicateNamespaceException;
import dev.relism.fpr.core.ByteView;
import dev.relism.http.HttpMethod;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Top-level dispatcher. Routes to the longest-matching mounted sub-router first,
* falling back to the internal {@link FastPathRouterImpl}.
*/
public class GlobalRouter extends AbstractRouter {
private final Map<String, AbstractRouter> subRoutersMap = new HashMap<>();
private final List<AbstractRouter> sortedSubRouters = new ArrayList<>();
private final AbstractRouter internalRouter;
public GlobalRouter(Middleware... middlewares) {
super(middlewares);
this.internalRouter = new FastPathRouterImpl();
}
public GlobalRouter() { this(new Middleware[0]); }
public void mount(String namespace, AbstractRouter router) {
String sanitized = PathUtils.sanitize(namespace);
if (subRoutersMap.containsKey(sanitized)) throw new DuplicateNamespaceException(sanitized);
router.setNamespace(sanitized);
subRoutersMap.put(sanitized, router);
sortedSubRouters.add(router);
sortedSubRouters.sort(Comparator.comparingInt((AbstractRouter r) -> r.getNamespaceBytes().length).reversed());
}
@Override
public RequestHandler route(Request request) {
ByteView path = request.getRequestLine().getPath();
for (AbstractRouter sub : sortedSubRouters) {
if (startsWith(path, sub.getNamespaceBytes())) {
RequestHandler h = sub.route(request);
return h != null ? h : sub.getNotFoundHandler();
}
}
RequestHandler h = internalRouter.route(request);
return h != null ? h : notFoundHandler;
}
public ExceptionHandler resolveExceptionHandler(Request request) {
ByteView path = request.getRequestLine().getPath();
for (AbstractRouter sub : sortedSubRouters) {
if (startsWith(path, sub.getNamespaceBytes())) return sub.getExceptionHandler();
}
return exceptionHandler;
}
@Override
protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) {
return internalRouter.addRoute(method, PathUtils.sanitize(path), handler);
}
private static boolean startsWith(ByteView view, byte[] prefix) {
if (view.length() < prefix.length) return false;
for (int i = 0; i < prefix.length; i++) {
if (view.byteAt(i) != prefix[i]) return false;
}
return true;
}
}
@@ -9,8 +9,6 @@ import dev.relism.http.HttpMethod;
import dev.relism.models.Request; import dev.relism.models.Request;
import dev.relism.models.RequestHandler; import dev.relism.models.RequestHandler;
import dev.relism.routing.AbstractRouter; import dev.relism.routing.AbstractRouter;
import dev.relism.routing.Middleware;
import dev.relism.routing.PathUtils;
/** /**
* Router backed by the {@code fpr-core} byte-level state machine. Routes are compiled lazily * Router backed by the {@code fpr-core} byte-level state machine. Routes are compiled lazily
@@ -23,8 +21,7 @@ public class FastPathRouterImpl extends AbstractRouter {
private volatile FastPathRouter<ByteView, RequestHandler> router; private volatile FastPathRouter<ByteView, RequestHandler> router;
private String[] cachedParamNames; private String[] cachedParamNames;
public FastPathRouterImpl(Middleware... middlewares) { super(middlewares); } public FastPathRouterImpl() {}
public FastPathRouterImpl() { super(); }
private static final class FastPathRouterContext { private static final class FastPathRouterContext {
private static final ThreadLocal<MatchResult<RequestHandler>> RESULT_HOLDER = private static final ThreadLocal<MatchResult<RequestHandler>> RESULT_HOLDER =
@@ -43,8 +40,7 @@ public class FastPathRouterImpl extends AbstractRouter {
@Override @Override
protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) { protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) {
String fullPath = PathUtils.join(namespace, path); builder.add(StringRouteParser.parse(method.name() + path), handler);
builder.add(StringRouteParser.parse(method.name() + fullPath), handler);
this.router = null; this.router = null;
return this; return this;
} }
@@ -8,22 +8,17 @@ import dev.relism.models.Response;
import dev.relism.models.SimpleHandler; import dev.relism.models.SimpleHandler;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
class AbstractRouterTest { class AbstractRouterTest {
// 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;
String lastAddedPath; String lastAddedPath;
@Override @Override
public RequestHandler route(Request request) { public RequestHandler route(Request request) { return null; }
return null;
}
@Override @Override
protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) { protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) {
@@ -34,31 +29,15 @@ class AbstractRouterTest {
} }
} }
// --- namespace ---
@Test @Test
void setNamespace_updatesStringAndBytes() { void doRegister_sanitizesPathAndWrapsHandler() {
DummyRouter router = new DummyRouter(); DummyRouter router = new DummyRouter();
assertEquals("/", router.getNamespace()); router.doRegister(HttpMethod.GET, "users/", new SimpleHandler((req, res) -> "OK"), new Middleware[0]);
router.setNamespace("/api");
assertEquals("/api", router.getNamespace());
assertArrayEquals("/api".getBytes(StandardCharsets.UTF_8), router.getNamespaceBytes());
}
// --- doRegister (infrastructure method used by FlashApp/FlashScope) ---
@Test
void doRegister_lambda_sanitizesPathAndWrapsHandler() {
DummyRouter router = new DummyRouter();
SimpleHandler.FunctionalHandler func = (req, res) -> "OK";
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);
assertNotNull(router.lastAddedHandler); assertNotNull(router.lastAddedHandler);
router.doRegister(HttpMethod.DELETE, "//delete//", func, new Middleware[0]); router.doRegister(HttpMethod.DELETE, "//delete//", new SimpleHandler((req, res) -> "OK"), new Middleware[0]);
assertEquals(HttpMethod.DELETE, router.lastAddedMethod); assertEquals(HttpMethod.DELETE, router.lastAddedMethod);
assertEquals("/delete", router.lastAddedPath); assertEquals("/delete", router.lastAddedPath);
} }
@@ -69,17 +48,13 @@ class AbstractRouterTest {
public Object handle(Request request, Response response) { return null; } 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 doRegister_annotatedHandler_addsRoute() { void doRegister_classBasedHandler_addsRoute() {
DummyRouter router = new DummyRouter(); DummyRouter router = new DummyRouter();
ProfileHandler handler = new ProfileHandler(); ProfileHandler handler = new ProfileHandler();
Route ann = ProfileHandler.class.getAnnotation(Route.class);
router.doRegister(handler, new Middleware[0]); router.doRegister(ann.method(), ann.path(), handler, new Middleware[0]);
assertEquals(HttpMethod.POST, router.lastAddedMethod); assertEquals(HttpMethod.POST, router.lastAddedMethod);
assertEquals("/profile", router.lastAddedPath); assertEquals("/profile", router.lastAddedPath);
@@ -87,27 +62,16 @@ class AbstractRouterTest {
} }
@Test @Test
void doRegister_unannotatedHandler_doesNothing() { void defaultNotFoundHandler_returns404() throws Exception {
DummyRouter router = new DummyRouter(); DummyRouter router = new DummyRouter();
router.doRegister(new UnannotatedHandler(), new Middleware[0]);
assertNull(router.lastAddedMethod);
}
// --- error handlers ---
@Test
void defaultNotFoundHandler_returns404Html() throws Exception {
DummyRouter router = new DummyRouter();
Response res = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
assertNotNull(router.getNotFoundHandler()); assertNotNull(router.getNotFoundHandler());
router.onNotFound((req, resp) -> "Custom 404"); router.onNotFound((req, resp) -> "Custom 404");
assertEquals("Custom 404", router.getNotFoundHandler().handle(null, res)); assertEquals("Custom 404", router.getNotFoundHandler().handle(null, null));
} }
@Test @Test
void defaultExceptionHandler_canBeOverridden() throws Exception { void defaultExceptionHandler_canBeOverridden() {
DummyRouter router = new DummyRouter(); DummyRouter router = new DummyRouter();
assertNotNull(router.getExceptionHandler()); assertNotNull(router.getExceptionHandler());
@@ -1,97 +0,0 @@
package dev.relism.routing;
import dev.relism.fpr.core.ByteView;
import dev.relism.http.HttpMethod;
import dev.relism.models.HeaderMap;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import dev.relism.models.RequestLine;
import dev.relism.models.SimpleHandler;
import dev.relism.routing.routers.fastpathrouter.FastPathViews;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
class GlobalRouterTest {
// --- mock ---
static class MockSubRouter extends AbstractRouter {
RequestHandler matchedHandler;
MockSubRouter(RequestHandler handler) {
this.matchedHandler = handler;
}
@Override
public RequestHandler route(Request request) {
return matchedHandler;
}
@Override
protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) {
return this;
}
}
private Request mockRequest(String path) {
byte[] bytes = path.getBytes(StandardCharsets.UTF_8);
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length);
RequestLine line = new RequestLine(
HttpMethod.GET, pathView, null,
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
new HeaderMap()
);
return new Request(line, new byte[0]);
}
// --- mount & route ---
@Test
void route_delegatesToSubRouterBasedOnLongestPrefix() {
GlobalRouter global = new GlobalRouter();
RequestHandler hApi = new SimpleHandler((req, res) -> "api");
RequestHandler hApiV1 = new SimpleHandler((req, res) -> "apiv1");
global.mount("/api", new MockSubRouter(hApi));
global.mount("/api/v1", new MockSubRouter(hApiV1));
assertEquals(hApiV1, global.route(mockRequest("/api/v1/users")));
assertEquals(hApi, global.route(mockRequest("/api/v2/users")));
}
@Test
void route_fallsBackToInternalRouter() throws Exception {
GlobalRouter global = new GlobalRouter();
global.doRegister(HttpMethod.GET, "/hello", (req, res) -> "internal", new Middleware[0]);
RequestHandler resolved = global.route(mockRequest("/hello"));
assertNotNull(resolved);
assertEquals("internal", resolved.handle(null, null));
}
@Test
void route_noMatch_returnsNotFoundHandler() {
GlobalRouter global = new GlobalRouter();
RequestHandler resolved = global.route(mockRequest("/unknown"));
assertEquals(global.getNotFoundHandler(), resolved);
}
// --- resolveExceptionHandler ---
@Test
void resolveExceptionHandler_returnsScopedHandler() {
GlobalRouter global = new GlobalRouter();
MockSubRouter sub = new MockSubRouter(null);
AbstractRouter.ExceptionHandler customSubHandler = (ex, req, res) -> "sub error";
sub.onException(customSubHandler);
global.mount("/api", sub);
assertEquals(customSubHandler, global.resolveExceptionHandler(mockRequest("/api/fail")));
assertEquals(global.getExceptionHandler(), global.resolveExceptionHandler(mockRequest("/other")));
}
}
@@ -5,6 +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 dev.relism.routing.Middleware;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -14,9 +15,7 @@ import static org.junit.jupiter.api.Assertions.*;
class FastPathRouterImplTest { class FastPathRouterImplTest {
private static final Middleware[] NO_MIDDLEWARE = new Middleware[0]; private static final Middleware[] NO_MW = new Middleware[0];
// --- 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);
@@ -29,14 +28,11 @@ class FastPathRouterImplTest {
return new Request(line, new byte[0]); return new Request(line, new byte[0]);
} }
// --- route ---
@Test @Test
void route_lazyCompilationAndMatch() throws Exception { void route_lazyCompilationAndMatch() throws Exception {
FastPathRouterImpl router = new FastPathRouterImpl(); FastPathRouterImpl router = new FastPathRouterImpl();
router.doRegister(HttpMethod.GET, "/a", new SimpleHandler((req, res) -> "A"), NO_MW);
router.doRegister(HttpMethod.GET, "/a", (req, res) -> "A", NO_MIDDLEWARE); router.doRegister(HttpMethod.POST, "/b", new SimpleHandler((req, res) -> "B"), NO_MW);
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);
@@ -50,7 +46,7 @@ class FastPathRouterImplTest {
@Test @Test
void route_noMatch_returnsNull() { void route_noMatch_returnsNull() {
FastPathRouterImpl router = new FastPathRouterImpl(); FastPathRouterImpl router = new FastPathRouterImpl();
router.doRegister(HttpMethod.GET, "/a", (req, res) -> "A", NO_MIDDLEWARE); router.doRegister(HttpMethod.GET, "/a", new SimpleHandler((req, res) -> "A"), NO_MW);
assertNull(router.route(mockRequest(HttpMethod.GET, "/b"))); assertNull(router.route(mockRequest(HttpMethod.GET, "/b")));
assertNull(router.route(mockRequest(HttpMethod.POST, "/a"))); assertNull(router.route(mockRequest(HttpMethod.POST, "/a")));
@@ -60,7 +56,7 @@ class FastPathRouterImplTest {
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.doRegister(HttpMethod.GET, "/users/{id}/items/{itemId}",
(req, res) -> "Extract", NO_MIDDLEWARE); new SimpleHandler((req, res) -> "Extract"), NO_MW);
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);
+10
View File
@@ -58,6 +58,16 @@
<artifactId>flash-ext-routeviewer</artifactId> <artifactId>flash-ext-routeviewer</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-view</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-limiter</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>fpr-core</artifactId> <artifactId>fpr-core</artifactId>