preparing for another refactoring...
This commit is contained in:
Generated
+4
@@ -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-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-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/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/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/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/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" />
|
||||
|
||||
Generated
+73
-14
@@ -4,31 +4,75 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="pre-major refactoring + ext api.">
|
||||
<change beforePath="$PROJECT_DIR$/.gitignore" beforeDir="false" afterPath="$PROJECT_DIR$/.gitignore" afterDir="false" />
|
||||
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="preparing for a conceptual refactoring...">
|
||||
<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/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/README.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/ext/jackson/JacksonExtension.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/README.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/Authenticated.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/Authenticated.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-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/OidcExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcMiddleware.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcMiddleware.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/README.md" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiExtension.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiExtension.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash-extensions/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/src/main/java/dev/relism/HttpServer.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServer.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/HttpServerConfiguration.java" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/ExtensionContext.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/main/java/dev/relism/extension/ExtensionContext.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/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/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/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/test/java/dev/relism/HttpServerConcurrencyTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerConcurrencyTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerTest.java" beforeDir="false" afterPath="$PROJECT_DIR$/flash/src/test/java/dev/relism/HttpServerTest.java" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/flash/src/main/java/dev/relism/routing/GlobalRouter.java" beforeDir="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/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$/pom.xml" beforeDir="false" afterPath="$PROJECT_DIR$/pom.xml" afterDir="false" />
|
||||
</list>
|
||||
@@ -44,7 +88,7 @@
|
||||
</persistenceIdMap>
|
||||
</component>
|
||||
<component name="EmbeddingIndexingInfo">
|
||||
<option name="cachedIndexableFilesCount" value="203" />
|
||||
<option name="cachedIndexableFilesCount" value="226" />
|
||||
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
|
||||
</component>
|
||||
<component name="FileTemplateManagerImpl">
|
||||
@@ -99,6 +143,8 @@
|
||||
"Maven.flash-bench [install].executor": "Run",
|
||||
"Maven.flash-bench [package].executor": "Run",
|
||||
"Maven.flash-bench [validate].executor": "Run",
|
||||
"Maven.flash-ext-limiter [install].executor": "Run",
|
||||
"Maven.flash-ext-limiter [package].executor": "Run",
|
||||
"Maven.flash-parent [install].executor": "Run",
|
||||
"Maven.flash-parent [package].executor": "Run",
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
@@ -271,6 +317,10 @@
|
||||
<workItem from="1774628513273" duration="86000" />
|
||||
<workItem from="1774638461244" duration="5718000" />
|
||||
<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 id="LOCAL-00001" summary="Initial">
|
||||
<option name="closed" value="true" />
|
||||
@@ -336,7 +386,15 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1774530802482</updated>
|
||||
</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 />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -374,7 +432,8 @@
|
||||
<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="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 name="XSLT-Support.FileAssociations.UIState">
|
||||
<expand />
|
||||
|
||||
@@ -124,7 +124,7 @@ app.mount("/api", scope -> {
|
||||
## Extensions
|
||||
|
||||
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
|
||||
FlashApp.create(8080)
|
||||
|
||||
+47
-42
@@ -2,71 +2,76 @@ package dev.relism.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import dev.relism.exceptions.HttpException;
|
||||
import dev.relism.extension.ExtensionContext;
|
||||
import dev.relism.extension.FlashContext;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
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:
|
||||
* <ul>
|
||||
* <li>Exposes the {@link ObjectMapper} in {@link ExtensionContext} — consumed by
|
||||
* {@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>Exposes a {@link Json} utility instance in the {@link FlashContext} under
|
||||
* {@code Json.class}. Any handler or extension in the same scope can retrieve
|
||||
* it via {@code require(Json.class)} inside {@code onInit()}.
|
||||
*
|
||||
* <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
|
||||
* FlashApp.of(new HttpServer(config))
|
||||
* .install(new JacksonExtension());
|
||||
* // No mandatory base class. Works from any RequestHandler.
|
||||
* 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()
|
||||
* .addModule(new JavaTimeModule())
|
||||
* .build();
|
||||
* .install(new JacksonExtension(mapper));
|
||||
* .addModule(new JavaTimeModule())
|
||||
* .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
* .build();
|
||||
*
|
||||
* FlashApp.create(8080)
|
||||
* .install(new JacksonExtension(mapper));
|
||||
* }</pre>
|
||||
*/
|
||||
public class JacksonExtension implements FlashExtension {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
/** Installs with a default {@link JsonMapper} (no extra modules). */
|
||||
public JacksonExtension() {
|
||||
this(JsonMapper.builder().build());
|
||||
}
|
||||
|
||||
/** Installs with a fully configured custom {@link ObjectMapper}. */
|
||||
public JacksonExtension(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void install(FlashRegistrar app, ExtensionContext ctx) {
|
||||
ctx.provide(ObjectMapper.class, mapper);
|
||||
JacksonHandler.mapper = mapper;
|
||||
|
||||
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");
|
||||
public void install(FlashRegistrar app, FlashContext ctx) {
|
||||
Json json = new Json(mapper);
|
||||
ctx.provide(Json.class, json);
|
||||
ctx.provide(ObjectMapper.class, mapper); // backward compat for extensions (OpenAPI, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
-77
@@ -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 1–100 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);
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+11
@@ -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) {}
|
||||
+45
@@ -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();
|
||||
}
|
||||
+82
@@ -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;
|
||||
}
|
||||
}
|
||||
+130
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
+54
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import dev.relism.extension.ExtensionContext;
|
||||
import dev.relism.extension.FlashContext;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
import dev.relism.extension.FlashRegistrar;
|
||||
|
||||
@@ -59,7 +59,7 @@ public class OidcExtension implements FlashExtension {
|
||||
}
|
||||
|
||||
@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)
|
||||
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.
|
||||
*/
|
||||
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) {
|
||||
ctx.find(dev.relism.ext.openapi.OpenApiSecurityRegistry.class)
|
||||
.ifPresent(registry -> registry.add(new dev.relism.ext.openapi.OpenApiSecurityContributor() {
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import java.util.Map;
|
||||
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
|
||||
* {@link Authenticated} or {@link RolesAllowed}.
|
||||
*
|
||||
|
||||
@@ -121,7 +121,7 @@ builder picks it up automatically — no coupling between extensions.
|
||||
|
||||
### 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.
|
||||
3. At spec build time, `OpenApiBuilder` iterates contributors and injects `security` entries on each
|
||||
operation whose handler class carries `@Authenticated` or `@RolesAllowed`.
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ package dev.relism.ext.openapi;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
|
||||
import dev.relism.extension.ExtensionContext;
|
||||
import dev.relism.extension.FlashContext;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
import dev.relism.extension.FlashRegistrar;
|
||||
import dev.relism.http.ContentType;
|
||||
@@ -61,7 +61,7 @@ public class OpenApiExtension implements FlashExtension {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void install(FlashRegistrar app, ExtensionContext ctx) {
|
||||
public void install(FlashRegistrar app, FlashContext ctx) {
|
||||
ObjectMapper jsonMapper = ctx.require(ObjectMapper.class);
|
||||
YAMLMapper yamlMapper = new YAMLMapper();
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import java.util.Map;
|
||||
*
|
||||
* <p>Extensions that enforce authentication (e.g. {@code flash-ext-oidc}) implement
|
||||
* 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.
|
||||
*
|
||||
* <p>Multi-tenant: multiple contributors may coexist. For handlers secured by
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
|
||||
/**
|
||||
* 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})
|
||||
* retrieve it via {@code ctx.find(OpenApiSecurityRegistry.class)} and register their
|
||||
* 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_Y = 60
|
||||
|
||||
const ABSTRACT_W = 160
|
||||
const ABSTRACT_W = 220
|
||||
const ABSTRACT_H = 50
|
||||
const CONCRETE_W = 260
|
||||
const LAMBDA_W = 230
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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 = {
|
||||
GET: { bg: '#0d4429', color: '#4ade80' },
|
||||
POST: { bg: '#172554', color: '#60a5fa' },
|
||||
@@ -10,15 +16,17 @@ const METHOD_COLORS = {
|
||||
HEAD: { bg: '#1c1917', color: '#a8a29e' },
|
||||
}
|
||||
|
||||
// Handles for LR layout: parent flows in from the LEFT, children exit to the RIGHT
|
||||
const TARGET_HANDLE = <Handle type="target" position={Position.Left}
|
||||
style={{ left: 0, top: '50%', transform: 'translateY(-50%)' }} />
|
||||
const SOURCE_HANDLE = <Handle type="source" position={Position.Right}
|
||||
style={{ right: 0, top: '50%', transform: 'translateY(-50%)' }} />
|
||||
// Shared truncation style — applied to any single-line text that can overflow.
|
||||
const TRUNCATE = {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }) {
|
||||
|
||||
@@ -30,16 +38,17 @@ export default function HandlerNode({ data, selected }) {
|
||||
border: selected ? '2px solid #60a5fa' : '1px solid #ef4444',
|
||||
borderRadius: 8,
|
||||
padding: '8px 14px',
|
||||
width: 160,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
width: 220,
|
||||
overflow: 'hidden',
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
}}>
|
||||
{TARGET_HANDLE}
|
||||
{SOURCE_HANDLE}
|
||||
<div style={{ fontSize: 9, color: '#ef4444', fontWeight: 700, letterSpacing: 0.5, marginBottom: 3 }}>
|
||||
ABSTRACT
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#e2e8f0', fontFamily: 'monospace' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#e2e8f0', fontFamily: 'monospace', ...TRUNCATE }}>
|
||||
{data.name}
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,9 +57,9 @@ export default function HandlerNode({ data, selected }) {
|
||||
|
||||
// ── LAMBDA ────────────────────────────────────────────────────────────
|
||||
if (data.isLambda) {
|
||||
const method = data.method || 'GET'
|
||||
const path = data.path || '/'
|
||||
const m = METHOD_COLORS[method] || METHOD_COLORS.OPTIONS
|
||||
const method = data.method || 'GET'
|
||||
const path = data.path || '/'
|
||||
const m = METHOD_COLORS[method] || METHOD_COLORS.OPTIONS
|
||||
const pathHtml = path.replace(/\{([^}]+)\}/g, '<span style="color:#a78bfa">{$1}</span>')
|
||||
|
||||
return (
|
||||
@@ -60,15 +69,16 @@ export default function HandlerNode({ data, selected }) {
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
width: 230,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
overflow: 'hidden',
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
}}>
|
||||
{TARGET_HANDLE}
|
||||
{SOURCE_HANDLE}
|
||||
<div style={{ fontSize: 9, color: '#60a5fa', fontWeight: 700, letterSpacing: 0.5, marginBottom: 7 }}>
|
||||
LAMBDA
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, overflow: 'hidden' }}>
|
||||
<span style={{
|
||||
background: m.bg, color: m.color,
|
||||
fontSize: 9, fontWeight: 700, padding: '2px 5px',
|
||||
@@ -77,7 +87,7 @@ export default function HandlerNode({ data, selected }) {
|
||||
{method}
|
||||
</span>
|
||||
<span
|
||||
style={{ fontSize: 10, fontFamily: 'monospace', color: '#8892a4' }}
|
||||
style={{ fontSize: 10, fontFamily: 'monospace', color: '#8892a4', ...TRUNCATE }}
|
||||
dangerouslySetInnerHTML={{ __html: pathHtml }}
|
||||
/>
|
||||
</div>
|
||||
@@ -88,6 +98,7 @@ export default function HandlerNode({ data, selected }) {
|
||||
background: '#1f1a0e', color: '#f6ad55',
|
||||
fontSize: 8, fontWeight: 600, padding: '1px 4px',
|
||||
borderRadius: 2, fontFamily: 'monospace',
|
||||
...TRUNCATE, maxWidth: '100%',
|
||||
}}>{mw}</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -104,18 +115,18 @@ export default function HandlerNode({ data, selected }) {
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
width: 260,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
overflow: 'hidden',
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
}}>
|
||||
{TARGET_HANDLE}
|
||||
{SOURCE_HANDLE}
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 9, color: '#60a5fa', fontWeight: 700, letterSpacing: 0.5, marginBottom: 2 }}>
|
||||
HANDLER
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: '#e2e8f0', fontFamily: 'monospace' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: '#e2e8f0', fontFamily: 'monospace', ...TRUNCATE }}>
|
||||
{data.name}
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,7 +136,7 @@ export default function HandlerNode({ data, selected }) {
|
||||
{/* Routes */}
|
||||
<div style={{ marginBottom: data.middleware?.length > 0 ? 8 : 0 }}>
|
||||
{data.routes?.map((route, i) => {
|
||||
const m = METHOD_COLORS[route.method] || METHOD_COLORS.OPTIONS
|
||||
const m = METHOD_COLORS[route.method] || METHOD_COLORS.OPTIONS
|
||||
const pathHtml = (route.path || '/').replace(
|
||||
/\{([^}]+)\}/g,
|
||||
'<span style="color:#a78bfa">{$1}</span>'
|
||||
@@ -134,6 +145,7 @@ export default function HandlerNode({ data, selected }) {
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
marginBottom: i < data.routes.length - 1 ? 5 : 0,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<span style={{
|
||||
background: m.bg, color: m.color,
|
||||
@@ -143,7 +155,7 @@ export default function HandlerNode({ data, selected }) {
|
||||
{route.method}
|
||||
</span>
|
||||
<span
|
||||
style={{ fontSize: 10, fontFamily: 'monospace', color: '#8892a4' }}
|
||||
style={{ fontSize: 10, fontFamily: 'monospace', color: '#8892a4', ...TRUNCATE }}
|
||||
dangerouslySetInnerHTML={{ __html: pathHtml }}
|
||||
/>
|
||||
</div>
|
||||
@@ -159,6 +171,7 @@ export default function HandlerNode({ data, selected }) {
|
||||
background: '#1f1a0e', color: '#f6ad55',
|
||||
fontSize: 8, fontWeight: 600, padding: '1px 4px',
|
||||
borderRadius: 2, fontFamily: 'monospace',
|
||||
...TRUNCATE, maxWidth: '100%',
|
||||
}}>{mw}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
package dev.relism.ext.routeviewer;
|
||||
|
||||
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.FlashRegistrar;
|
||||
import dev.relism.http.ContentType;
|
||||
@@ -36,7 +36,7 @@ import dev.relism.http.ContentType;
|
||||
* }</pre>
|
||||
*
|
||||
* <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 {
|
||||
|
||||
@@ -55,7 +55,7 @@ public class RouteViewerExtension implements FlashExtension {
|
||||
public RouteViewerExtension(String path) { this.path = path; }
|
||||
|
||||
@Override
|
||||
public void install(FlashRegistrar app, ExtensionContext ctx) {
|
||||
public void install(FlashRegistrar app, FlashContext ctx) {
|
||||
RouteViewerHandler shell = new RouteViewerHandler();
|
||||
RouteViewerDataHandler data = new RouteViewerDataHandler(graph);
|
||||
|
||||
|
||||
+25
-1
@@ -47,6 +47,11 @@ public record RouteRecord(
|
||||
/**
|
||||
* Walks the superclass chain, stopping before {@code RequestHandler}.
|
||||
* {@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) {
|
||||
if (cls == null) return List.of();
|
||||
@@ -54,12 +59,31 @@ public record RouteRecord(
|
||||
Class<?> c = cls;
|
||||
while (c != null && !c.equals(Object.class)) {
|
||||
if (ROOT_HANDLER.equals(c.getSimpleName())) break;
|
||||
chain.add(c.getSimpleName());
|
||||
chain.add(displayName(c));
|
||||
c = c.getSuperclass();
|
||||
}
|
||||
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) {
|
||||
if (cls == null) return List.of();
|
||||
List<String> pointcuts = new ArrayList<>();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
}
|
||||
}
|
||||
+146
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
<module>flash-ext-openapi</module>
|
||||
<module>flash-ext-oidc</module>
|
||||
<module>flash-ext-routeviewer</module>
|
||||
<module>flash-ext-view</module>
|
||||
<module>flash-ext-limiter</module>
|
||||
</modules>
|
||||
|
||||
<dependencyManagement>
|
||||
|
||||
@@ -5,11 +5,12 @@ import dev.relism.http.ContentType;
|
||||
import dev.relism.http.HttpStatus;
|
||||
import dev.relism.models.*;
|
||||
import dev.relism.extension.FlashConfiguration;
|
||||
import dev.relism.routing.GlobalRouter;
|
||||
import dev.relism.routing.AbstractRouter;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -21,32 +22,32 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Pure I/O transport layer. Owns the {@link ServerSocket}, the virtual-thread executor,
|
||||
* and the keep-alive accept loop. All routing is delegated to the {@link GlobalRouter}
|
||||
* supplied at construction time.
|
||||
* Pure I/O transport layer. Owns the {@link ServerSocket}, the virtual-thread
|
||||
* executor, and the keep-alive accept loop. Routing is delegated to a single
|
||||
* {@link AbstractRouter}.
|
||||
*
|
||||
* <p>This class is package-private — use {@link dev.relism.extension.FlashApp} as the
|
||||
* single entry point for creating and configuring a Flash server.
|
||||
* <p>Package-private : use {@link dev.relism.extension.FlashApp} as the single
|
||||
* entry point.
|
||||
*/
|
||||
@Slf4j
|
||||
class HttpServer implements ServerHandle {
|
||||
|
||||
private final FlashConfiguration configuration;
|
||||
private final ServerSocket serverSocket;
|
||||
private final GlobalRouter globalRouter;
|
||||
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
|
||||
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
|
||||
private final ServerSocket serverSocket;
|
||||
private final AbstractRouter router;
|
||||
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
|
||||
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
|
||||
private volatile boolean stopped = false;
|
||||
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[] CRLF = "\r\n".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[] CONNECTION_CLOSE = "Connection: close\r\n".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[] CONTENT_TYPE = "Content-Type: ".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_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[] FINAL_CHUNK = "0\r\n\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[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
HttpServer(FlashConfiguration configuration, AbstractRouter router) throws IOException {
|
||||
this.configuration = configuration;
|
||||
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
|
||||
public CompletableFuture<Void> start() {
|
||||
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
|
||||
public CompletableFuture<Void> stop() {
|
||||
stopped = true;
|
||||
try {
|
||||
serverSocket.close();
|
||||
} catch (IOException e) {
|
||||
log.error("Error closing server socket", e);
|
||||
}
|
||||
try { serverSocket.close(); } catch (IOException e) { log.error("Error closing server socket", e); }
|
||||
activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) {} });
|
||||
executorService.shutdown();
|
||||
try {
|
||||
@@ -111,7 +99,7 @@ class HttpServer implements ServerHandle {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
// ── Hot-path ──────────────────────────────────────────────────────────────
|
||||
// ── Hot-path ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void process(Socket socket) {
|
||||
activeSockets.add(socket);
|
||||
@@ -119,36 +107,32 @@ class HttpServer implements ServerHandle {
|
||||
try (socket;
|
||||
InputStream in = socket.getInputStream();
|
||||
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
|
||||
RequestParser parser = new RequestParser(configuration.getMaxHeaderBufferSize());
|
||||
RequestParser parser = new RequestParser(
|
||||
configuration.getMaxHeaderBufferSize(),
|
||||
(InetSocketAddress) socket.getRemoteSocketAddress());
|
||||
while (!stopped) {
|
||||
Request request = parser.parse(in);
|
||||
if (request == null)
|
||||
break;
|
||||
if (request == null) break;
|
||||
|
||||
boolean keepAlive = isKeepAlive(request);
|
||||
|
||||
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 {
|
||||
Object result = handler.handle(request, response);
|
||||
if (result instanceof Response r)
|
||||
response = r;
|
||||
else if (result != null)
|
||||
response.setBody(result);
|
||||
if (result instanceof Response r) response = r;
|
||||
else if (result != null) response.setBody(result);
|
||||
} catch (Exception ex) {
|
||||
Object result = globalRouter.resolveExceptionHandler(request).handle(ex, request, response);
|
||||
if (result instanceof Response r)
|
||||
response = r;
|
||||
else if (result != null)
|
||||
response.setBody(result);
|
||||
Object result = router.getExceptionHandler().handle(ex, request, response);
|
||||
if (result instanceof Response r) response = r;
|
||||
else if (result != null) response.setBody(result);
|
||||
}
|
||||
|
||||
writeResponse(out, response, keepAlive);
|
||||
request.drain();
|
||||
|
||||
if (!keepAlive)
|
||||
break;
|
||||
if (!keepAlive) break;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
if (!stopped) {
|
||||
@@ -170,11 +154,6 @@ class HttpServer implements ServerHandle {
|
||||
|| 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 {
|
||||
out.write(HTTP_1_1);
|
||||
byte[] statusBytes = response.getStatusBytes();
|
||||
|
||||
@@ -10,6 +10,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
@@ -21,15 +22,18 @@ import java.util.Arrays;
|
||||
public class RequestParser {
|
||||
private static final int INITIAL_BUFFER_SIZE = 8192;
|
||||
|
||||
private final int maxHeaderBufferSize;
|
||||
private final HeaderMap headerMap = new HeaderMap();
|
||||
private final int maxHeaderBufferSize;
|
||||
private final InetSocketAddress remoteAddress; // set once per connection, never changes
|
||||
private final HeaderMap headerMap = new HeaderMap();
|
||||
private byte[] buffer;
|
||||
private int bufBase = 0; // absolute start of valid data in buffer
|
||||
private int bufLen = 0; // number of valid bytes from bufBase
|
||||
|
||||
public RequestParser() { this(64 * 1024); }
|
||||
public RequestParser(int maxHeaderBufferSize) {
|
||||
public RequestParser() { this(64 * 1024, null); }
|
||||
public RequestParser(int maxHeaderBufferSize) { this(maxHeaderBufferSize, null); }
|
||||
public RequestParser(int maxHeaderBufferSize, InetSocketAddress remoteAddress) {
|
||||
this.maxHeaderBufferSize = maxHeaderBufferSize;
|
||||
this.remoteAddress = remoteAddress;
|
||||
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);
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
package dev.relism;
|
||||
|
||||
import dev.relism.extension.FlashConfiguration;
|
||||
import dev.relism.routing.GlobalRouter;
|
||||
import dev.relism.routing.AbstractRouter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Public handle to the underlying HTTP transport. Returned by {@link #create} so that
|
||||
* {@link dev.relism.extension.FlashApp} can start and stop the server without holding
|
||||
* a direct reference to the package-private {@link HttpServer}.
|
||||
* Public handle to the underlying HTTP transport. Returned by {@link #create}
|
||||
* so that {@link dev.relism.extension.FlashApp} can start and stop the server
|
||||
* without holding a direct reference to the package-private {@link HttpServer}.
|
||||
*/
|
||||
public interface ServerHandle {
|
||||
|
||||
CompletableFuture<Void> start();
|
||||
CompletableFuture<Void> stop();
|
||||
|
||||
/**
|
||||
* Creates the HTTP transport. Called exclusively by
|
||||
* {@link dev.relism.extension.FlashApp}.
|
||||
*/
|
||||
static ServerHandle create(FlashConfiguration config, GlobalRouter router) throws IOException {
|
||||
static ServerHandle create(FlashConfiguration config, AbstractRouter router) throws IOException {
|
||||
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
|
||||
* (e.g. collecting OpenAPI metadata).
|
||||
*
|
||||
* <p>Register processors via {@link ExtensionContext#addAnnotationProcessor}.
|
||||
* <p>Register processors via {@link FlashContext#addAnnotationProcessor}.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface AnnotationProcessor {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
package dev.relism.extension;
|
||||
|
||||
import dev.relism.ServerHandle;
|
||||
import dev.relism.exceptions.InitializationException;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.SimpleHandler;
|
||||
import dev.relism.routing.AbstractRouter;
|
||||
import dev.relism.routing.GlobalRouter;
|
||||
import dev.relism.routing.Middleware;
|
||||
import dev.relism.routing.Route;
|
||||
import dev.relism.routing.RouteHandle;
|
||||
import dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
@@ -16,98 +17,62 @@ import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Primary entry point for Flash. Creates and owns both the {@link GlobalRouter} and
|
||||
* the {@link HttpServer} (pure I/O transport). All route registration goes through
|
||||
* {@code FlashApp} or a {@link FlashScope} — never through the server or router directly.
|
||||
* Single entry point for Flash. Owns one flat {@link FastPathRouterImpl} —
|
||||
* all routes (app-level and scoped) compile into a single FSM at {@link #start()}.
|
||||
*
|
||||
* <p>Create via the static factories:
|
||||
* <pre>{@code
|
||||
* FlashApp app = FlashApp.create(8080);
|
||||
* FlashApp app = FlashApp.create(FlashConfiguration.builder().port(8080).build());
|
||||
* }</pre>
|
||||
* <h3>Deferred routing</h3>
|
||||
* Routes accumulate during the builder phase. At {@code start()}:
|
||||
* <ol>
|
||||
* <li>Global middlewares are prepended to every route</li>
|
||||
* <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
|
||||
* FlashApp.create(8080)
|
||||
* .install(new JacksonExtension())
|
||||
* .install(new OidcExtension(config))
|
||||
* .use(cors)
|
||||
* .get("/ping", (req, res) -> "pong")
|
||||
* .get("/me", (req, res) -> ClaimsHolder.user().email()).with(oidc.protect())
|
||||
* .register(new HomePage()) // @Route + annotation processors applied
|
||||
* .scan("dev.example.handlers") // classpath scan, no-arg constructors
|
||||
* .mount("/api", scope -> {
|
||||
* scope.register(new UserHandler()); // @Authenticated works here
|
||||
* scope.get("/health", (req, res) -> "ok");
|
||||
* })
|
||||
* .scan("dev.example.handlers")
|
||||
* .mount("/api", scope -> scope.get("/health", (req, res) -> "ok"))
|
||||
* .start();
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>Auto-flush</h3>
|
||||
* Calling any registration method returns a {@link RouteHandle}. Calling
|
||||
* {@link RouteHandle#with} is optional — if omitted, the route is registered
|
||||
* automatically before the next operation or at {@link #start()}. This means
|
||||
* trailing {@code .with()} calls are never required for routes with no middleware.
|
||||
*/
|
||||
public final class FlashApp implements FlashRegistrar {
|
||||
|
||||
private final GlobalRouter router;
|
||||
private final ServerHandle server;
|
||||
private final ExtensionContext ctx = new ExtensionContext();
|
||||
private final AbstractRouter router = new FastPathRouterImpl();
|
||||
private final ServerHandle server;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Global middlewares applied to every route, regardless of how it is registered
|
||||
* (lambda, class-based, or via {@link #scan}).
|
||||
* Accumulated via {@link #use}; applied outermost in the chain (before injected and
|
||||
* explicit middlewares).
|
||||
*/
|
||||
private final List<Middleware> globalMiddlewares = new ArrayList<>();
|
||||
|
||||
private FlashApp(FlashConfiguration config) {
|
||||
this.router = new GlobalRouter();
|
||||
try {
|
||||
this.server = ServerHandle.create(config, router);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to bind server socket on port " + config.getPort(), e);
|
||||
throw new InitializationException("Failed to bind on port " + config.getPort(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
return create(FlashConfiguration.builder().port(port).build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@code FlashApp} with full server configuration.
|
||||
*
|
||||
* @param config server configuration (port, host, buffer sizes, etc.)
|
||||
*/
|
||||
public static FlashApp create(FlashConfiguration config) {
|
||||
return new FlashApp(config);
|
||||
}
|
||||
|
||||
// ── Pending flush ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
if (pending != null) {
|
||||
pending.ensureRegistered();
|
||||
pending = null;
|
||||
}
|
||||
if (pending != null) { pending.ensureRegistered(); pending = null; }
|
||||
}
|
||||
|
||||
private <P> RouteHandle<P> track(RouteHandle<P> handle) {
|
||||
@@ -116,15 +81,8 @@ public final class FlashApp implements FlashRegistrar {
|
||||
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
|
||||
public FlashApp install(FlashExtension ext) {
|
||||
flushPending();
|
||||
@@ -132,34 +90,12 @@ public final class FlashApp implements FlashRegistrar {
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── Global middleware ──────────────────────────────────────────────────────
|
||||
// ── Global middleware ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registers one or more global middlewares applied to <em>every</em> route on this app,
|
||||
* regardless of how the route is registered (lambda, class-based, or via {@link #scan}).
|
||||
*
|
||||
* <p>Global middlewares execute outermost — before annotation-injected middlewares
|
||||
* (e.g. {@code @Authenticated}) and before any explicit {@link RouteHandle#with} chain.
|
||||
* Execution order mirrors the declaration order: the first argument wraps everything else.
|
||||
*
|
||||
* <p>Must be called before {@link #start()}. Calling {@code use} after routes have already
|
||||
* been registered will not retroactively affect those routes.
|
||||
*
|
||||
* <pre>{@code
|
||||
* Middleware cors = next -> (req, res) -> {
|
||||
* res.header("Access-Control-Allow-Origin", "*");
|
||||
* if (req.method() == HttpMethod.OPTIONS) { res.status(204); return null; }
|
||||
* return next.handle(req, res);
|
||||
* };
|
||||
*
|
||||
* FlashApp.create(8080)
|
||||
* .use(cors)
|
||||
* .scan("dev.example.handlers")
|
||||
* .start();
|
||||
* }</pre>
|
||||
*
|
||||
* @param middlewares one or more middlewares to apply globally
|
||||
* @return {@code this} for chaining
|
||||
* Registers global middlewares applied to <em>every</em> route — including
|
||||
* routes registered before this call and routes inside mounted scopes.
|
||||
* Order-independent: resolved at {@link #start()}.
|
||||
*/
|
||||
public FlashApp use(Middleware... middlewares) {
|
||||
flushPending();
|
||||
@@ -167,182 +103,157 @@ public final class FlashApp implements FlashRegistrar {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepends global middlewares to an explicit per-route array.
|
||||
* Returns {@code explicit} unchanged when no global middlewares have been registered
|
||||
* (zero-allocation fast path).
|
||||
*/
|
||||
private Middleware[] withGlobal(Middleware[] explicit) {
|
||||
if (globalMiddlewares.isEmpty()) return explicit;
|
||||
return Stream.concat(globalMiddlewares.stream(), Arrays.stream(explicit))
|
||||
.toArray(Middleware[]::new);
|
||||
}
|
||||
// ── Route registration (deferred) ─────────────────────────────────────────
|
||||
|
||||
// ── 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); }
|
||||
@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 static final Middleware[] NO_MW = new Middleware[0];
|
||||
|
||||
private RouteHandle<FlashApp> routeLambda(HttpMethod method, String path, SimpleHandler.FunctionalHandler h) {
|
||||
return track(new RouteHandle<>(this, m -> {
|
||||
Middleware[] all = withGlobal(m);
|
||||
emit(method, path, null, List.of(), all);
|
||||
router.doRegister(method, path, h, all);
|
||||
}));
|
||||
private RouteHandle<FlashApp> lambda(HttpMethod method, String path, SimpleHandler.FunctionalHandler h) {
|
||||
return track(new RouteHandle<>(this, mw ->
|
||||
deferredRoutes.add(new RouteDefinition(method, path, new SimpleHandler(h), NO_MW, mw, false, ctx, "/"))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins registration of a class-based handler. The class must carry a
|
||||
* {@link Route @Route} annotation. All registered {@link AnnotationProcessor}s
|
||||
* are run (e.g. to inject {@code @Authenticated} / {@code @RolesAllowed} middleware).
|
||||
* Injected middlewares are prepended outermost to any explicit ones passed via
|
||||
* {@link RouteHandle#with}.
|
||||
*
|
||||
* <p>Calling {@link RouteHandle#with} is optional — the route is registered
|
||||
* automatically before the next operation or at {@link #start()}.
|
||||
* Registers a class-based handler annotated with {@link Route @Route}.
|
||||
* App-level only — not part of {@link FlashRegistrar}.
|
||||
*/
|
||||
@Override
|
||||
public RouteHandle<FlashApp> register(RequestHandler handler) {
|
||||
return track(new RouteHandle<>(this, explicit -> {
|
||||
List<Middleware> injected = ctx.processors().stream()
|
||||
.flatMap(p -> p.process(handler.getClass()).stream())
|
||||
.toList();
|
||||
Middleware[] all = Stream.concat(
|
||||
globalMiddlewares.stream(),
|
||||
Stream.concat(injected.stream(), Arrays.stream(explicit))
|
||||
).toArray(Middleware[]::new);
|
||||
Route ann = handler.getClass().getAnnotation(Route.class);
|
||||
if (ann != null) emit(ann.method(), ann.path(), handler.getClass(), injected, withGlobal(explicit));
|
||||
router.doRegister(handler, all);
|
||||
}));
|
||||
Route ann = handler.getClass().getAnnotation(Route.class);
|
||||
if (ann == null)
|
||||
throw new InitializationException(
|
||||
handler.getClass().getName() + " is missing @Route");
|
||||
return track(new RouteHandle<>(this, mw ->
|
||||
deferredRoutes.add(new RouteDefinition(ann.method(), ann.path(), handler, NO_MW, mw, true, ctx, "/"))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans {@code packageName} for classes that extend {@link RequestHandler} and
|
||||
* carry {@link Route @Route}. Each is instantiated via its no-arg constructor,
|
||||
* run through annotation processors, and registered.
|
||||
*
|
||||
* <pre>{@code
|
||||
* FlashApp.create(8080)
|
||||
* .install(new OidcExtension(config))
|
||||
* .scan("dev.example.handlers"); // @Authenticated / @RolesAllowed auto-applied
|
||||
* }</pre>
|
||||
*/
|
||||
@Override
|
||||
public FlashApp scan(String packageName) {
|
||||
flushPending();
|
||||
PackageScanner.findHandlers(packageName).forEach(cls -> register(instantiate(cls)).ensureRegistered());
|
||||
PackageScanner.findHandlers(packageName).forEach(cls ->
|
||||
register(instantiate(cls)).ensureRegistered());
|
||||
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
|
||||
* receives a {@link FlashScope} that has its own child {@link ExtensionContext}
|
||||
* inheriting all parent services and annotation processors.
|
||||
*
|
||||
* <p>Routes registered on the scope automatically get the namespace prefix prepended.
|
||||
* Annotation processors (e.g. from OIDC) apply identically inside the scope.
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.mount("/api", scope -> {
|
||||
* scope.register(new UserHandler()); // @Authenticated works
|
||||
* scope.get("/health", (req, res) -> "ok");
|
||||
* scope.scan("dev.example.api");
|
||||
* });
|
||||
* }</pre>
|
||||
*
|
||||
* @param namespace the path prefix (e.g. {@code "/api"})
|
||||
* @param configure consumer that registers routes on the scope
|
||||
* Mounts a scoped group of routes under {@code namespace}. The scope is a
|
||||
* pure builder — it prepends the namespace to each path and collects
|
||||
* {@link RouteDefinition}s that merge into this app's single flat router.
|
||||
*/
|
||||
public FlashApp mount(String namespace, Consumer<FlashScope> configure) {
|
||||
flushPending();
|
||||
FlashScope scope = new FlashScope(new dev.relism.routing.routers.fastpathrouter.FastPathRouterImpl(),
|
||||
namespace, ctx);
|
||||
FlashScope scope = new FlashScope(namespace, ctx);
|
||||
configure.accept(scope);
|
||||
scope.flush();
|
||||
router.mount(namespace, scope.router());
|
||||
deferredRoutes.addAll(scope.routes());
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── FlashRegistrar — error handlers ──────────────────────────────────────
|
||||
// ── Error handlers ────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public FlashApp onException(AbstractRouter.ExceptionHandler handler) {
|
||||
flushPending();
|
||||
router.onException(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FlashApp onNotFound(SimpleHandler.FunctionalHandler handler) {
|
||||
flushPending();
|
||||
router.onNotFound(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── FlashRegistrar — context ──────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public ExtensionContext ctx() {
|
||||
return ctx;
|
||||
}
|
||||
public FlashContext ctx() { return ctx; }
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flushes any pending route registration and starts the HTTP server.
|
||||
*
|
||||
* @return a future that completes once the accept loop is running
|
||||
* Compiles all deferred routes into the flat FSM router, then starts
|
||||
* the HTTP transport. One pass, one router, zero prefix scanning.
|
||||
*/
|
||||
public CompletableFuture<Void> start() {
|
||||
flushPending();
|
||||
compile();
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@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) {
|
||||
try {
|
||||
return (RequestHandler) cls.getDeclaredConstructor().newInstance();
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
|
||||
+38
-46
@@ -4,46 +4,42 @@ import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Shared registry passed to every extension during {@link FlashExtension#install}.
|
||||
* Extensions use it in two ways:
|
||||
* Central service registry and boot-time hook coordinator.
|
||||
*
|
||||
* <p>Every handler, extension, and scope shares one (or a child of one) {@code FlashContext}.
|
||||
* It provides three capabilities:
|
||||
* <ol>
|
||||
* <li><b>Service sharing</b> — provide/require typed objects (e.g. {@code ObjectMapper},
|
||||
* {@code OpenApiBuilder}) so extensions can build on each other.</li>
|
||||
* <li><b>Annotation processing</b> — register {@link AnnotationProcessor}s that
|
||||
* are invoked for every handler, injecting middleware derived from
|
||||
* annotations ({@code @RolesAllowed}, {@code @Authenticated}, etc.).</li>
|
||||
* <li><b>Service registry</b> — typed {@link #provide}/{@link #require}/{@link #find}.</li>
|
||||
* <li><b>Annotation processors</b> — middleware injection from handler annotations.</li>
|
||||
* <li><b>Route listeners</b> — boot-time observation of the route graph.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>A child context (created via {@link #child()}) inherits all services and processors
|
||||
* from its parent. Services provided and processors added on the child are scoped to it
|
||||
* and not visible in the parent or sibling scopes.
|
||||
* <p>A child context (via {@link #child()}) inherits parent services and processors.
|
||||
* Services provided on the child are scoped and invisible to the parent.
|
||||
*/
|
||||
public class ExtensionContext {
|
||||
public class FlashContext {
|
||||
|
||||
private final ExtensionContext parent;
|
||||
private final FlashContext parent;
|
||||
private final Map<Class<?>, Object> registry = new LinkedHashMap<>();
|
||||
private final List<AnnotationProcessor> processors = new ArrayList<>();
|
||||
private final List<RouteListener> routeListeners = new ArrayList<>();
|
||||
|
||||
public ExtensionContext() {
|
||||
public FlashContext() {
|
||||
this.parent = null;
|
||||
}
|
||||
|
||||
private ExtensionContext(ExtensionContext parent) {
|
||||
private FlashContext(FlashContext parent) {
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a child context that inherits this context's services and processors.
|
||||
* Services provided and processors added on the child do not affect the parent.
|
||||
*/
|
||||
public ExtensionContext child() {
|
||||
return new ExtensionContext(this);
|
||||
/** Creates a child context that inherits this context's services and processors. */
|
||||
public FlashContext child() {
|
||||
return new FlashContext(this);
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
registry.put(type, instance);
|
||||
}
|
||||
@@ -51,7 +47,8 @@ public class ExtensionContext {
|
||||
/**
|
||||
* Retrieves the service registered under {@code type}.
|
||||
* Checks own scope first, then the parent chain.
|
||||
* Throws {@link IllegalStateException} if not found — install order matters.
|
||||
*
|
||||
* @throws IllegalStateException if not found
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
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)
|
||||
throw new IllegalStateException(
|
||||
"Extension dependency not found: " + type.getSimpleName() +
|
||||
" — install the required extension first");
|
||||
"Service not found: " + type.getSimpleName() +
|
||||
" — provide it via FlashContext.provide() or install the required extension");
|
||||
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")
|
||||
public <T> Optional<T> find(Class<T> type) {
|
||||
T val = (T) registry.get(type);
|
||||
@@ -72,21 +69,26 @@ public class ExtensionContext {
|
||||
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 ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registers an {@link AnnotationProcessor}. Called by extensions during
|
||||
* {@link FlashExtension#install}. Processors are invoked in registration order
|
||||
* (parent processors first, then own).
|
||||
*/
|
||||
/** Registers an {@link AnnotationProcessor}. Processors run once per class-based handler at boot. */
|
||||
public void addAnnotationProcessor(AnnotationProcessor processor) {
|
||||
processors.add(processor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all processors visible from this context: parent processors first,
|
||||
* then processors added directly to this context.
|
||||
*/
|
||||
/** All processors visible from this context: parent-first, then own. */
|
||||
List<AnnotationProcessor> processors() {
|
||||
if (parent == null) return Collections.unmodifiableList(processors);
|
||||
List<AnnotationProcessor> parentProcessors = parent.processors();
|
||||
@@ -96,22 +98,12 @@ public class ExtensionContext {
|
||||
|
||||
// ── Route listeners ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registers a {@link RouteListener} that will be notified once for every route
|
||||
* registered on this context's {@link dev.relism.extension.FlashApp} or any
|
||||
* {@link dev.relism.extension.FlashScope} that inherits from it.
|
||||
*
|
||||
* <p>Call this inside {@link FlashExtension#install} to observe all routes.
|
||||
* If no listener is registered the emission path is a no-op.
|
||||
*/
|
||||
/** Registers a boot-time {@link RouteListener}. Zero overhead on the request hot-path. */
|
||||
public void addRouteListener(RouteListener listener) {
|
||||
routeListeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all route listeners visible from this context: parent listeners first,
|
||||
* then listeners added directly to this context.
|
||||
*/
|
||||
/** All route listeners visible from this context: parent-first, then own. */
|
||||
List<RouteListener> routeListeners() {
|
||||
if (parent == null) return Collections.unmodifiableList(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}
|
||||
* (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
|
||||
* mounted scope:
|
||||
*
|
||||
* <pre>{@code
|
||||
* public class RateLimitExtension implements FlashExtension {
|
||||
* public void install(FlashRegistrar app, ExtensionContext ctx) {
|
||||
* public void install(FlashRegistrar app, FlashContext ctx) {
|
||||
* RateLimiter limiter = new RateLimiter(100);
|
||||
* 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
|
||||
public interface FlashExtension {
|
||||
void install(FlashRegistrar app, ExtensionContext ctx);
|
||||
void install(FlashRegistrar app, FlashContext ctx);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
package dev.relism.extension;
|
||||
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.SimpleHandler;
|
||||
import dev.relism.routing.AbstractRouter;
|
||||
import dev.relism.routing.RouteHandle;
|
||||
|
||||
/**
|
||||
* Common registration surface shared by {@link FlashApp} and {@link FlashScope}.
|
||||
*
|
||||
* <p>{@link FlashExtension#install} receives a {@code FlashRegistrar} so that extensions
|
||||
* <p>{@link FlashExtension#install} receives a {@code FlashRegistrar} so extensions
|
||||
* work identically whether installed at the top-level app or inside a mounted scope.
|
||||
*
|
||||
* <p>Route registration follows the auto-flush pattern: calling any registration method
|
||||
* without a subsequent {@link RouteHandle#with} is equivalent to calling
|
||||
* {@code .with()} with no arguments — the route is registered with no middleware.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // In an extension:
|
||||
* public void install(FlashRegistrar app, ExtensionContext ctx) {
|
||||
* public void install(FlashRegistrar app, FlashContext ctx) {
|
||||
* app.get("/health", (req, res) -> "ok");
|
||||
* app.get("/secured", (req, res) -> user()).with(oidc.protect());
|
||||
* }
|
||||
@@ -25,11 +19,9 @@ import dev.relism.routing.RouteHandle;
|
||||
*/
|
||||
public interface FlashRegistrar {
|
||||
|
||||
// ── Extension installation ────────────────────────────────────────────────
|
||||
|
||||
FlashRegistrar install(FlashExtension ext);
|
||||
|
||||
// ── Route registration ────────────────────────────────────────────────────
|
||||
// ── Lambda route registration ────────────────────────────────────────────
|
||||
|
||||
RouteHandle<?> get (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);
|
||||
|
||||
/**
|
||||
* Begins registration of a class-based handler. The class must carry a
|
||||
* {@link dev.relism.routing.Route @Route} annotation. Annotation processors
|
||||
* (e.g. {@code @Authenticated}, {@code @RolesAllowed}) are applied automatically.
|
||||
*
|
||||
* <p>Calling {@link RouteHandle#with} is optional — the route is registered
|
||||
* automatically before the next operation or at {@code start()}.
|
||||
*/
|
||||
RouteHandle<?> register(RequestHandler h);
|
||||
|
||||
/**
|
||||
* Scans {@code packageName} for classes that extend {@link RequestHandler} and
|
||||
* carry {@link dev.relism.routing.Route @Route}. Each is instantiated via its
|
||||
* Scans {@code packageName} for classes that extend
|
||||
* {@link dev.relism.models.RequestHandler} and carry
|
||||
* {@link dev.relism.routing.Route @Route}. Each is instantiated via its
|
||||
* no-arg constructor, run through annotation processors, and registered.
|
||||
*/
|
||||
FlashRegistrar scan(String packageName);
|
||||
|
||||
// ── Error handlers ────────────────────────────────────────────────────────
|
||||
|
||||
FlashRegistrar onException(AbstractRouter.ExceptionHandler h);
|
||||
FlashRegistrar onNotFound(SimpleHandler.FunctionalHandler h);
|
||||
|
||||
// ── Context access ────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the {@link ExtensionContext} for this registrar (app or scope). */
|
||||
ExtensionContext ctx();
|
||||
/** Returns the {@link FlashContext} for this registrar. */
|
||||
FlashContext ctx();
|
||||
}
|
||||
|
||||
@@ -1,62 +1,43 @@
|
||||
package dev.relism.extension;
|
||||
|
||||
import dev.relism.exceptions.InitializationException;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.SimpleHandler;
|
||||
import dev.relism.routing.AbstractRouter;
|
||||
import dev.relism.routing.Middleware;
|
||||
import dev.relism.routing.PathUtils;
|
||||
import dev.relism.routing.Route;
|
||||
import dev.relism.routing.RouteHandle;
|
||||
import dev.relism.routing.Middleware;
|
||||
|
||||
import java.io.File;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Scoped registration context for a mounted sub-router namespace.
|
||||
*
|
||||
* <p>Obtained via {@link FlashApp#mount(String, java.util.function.Consumer)}.
|
||||
* A scope has its own child {@link ExtensionContext} that inherits all services and
|
||||
* annotation processors from the parent app, so extensions like {@code @Authenticated}
|
||||
* and {@code @RolesAllowed} work identically inside a scope.
|
||||
*
|
||||
* <p>Extensions installed on a scope are scoped to that namespace and not visible
|
||||
* in the parent or sibling scopes.
|
||||
* Scoped builder for namespace-prefixed routes. Pure syntactic sugar —
|
||||
* does not own a router. Collected routes merge into {@link FlashApp}'s
|
||||
* single flat router at {@link FlashApp#start()}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.mount("/api", scope -> {
|
||||
* scope.install(new RateLimitExtension());
|
||||
* scope.register(new UserHandler()); // @Authenticated auto-injected
|
||||
* scope.get("/health", (req, res) -> "ok");
|
||||
* scope.use(authMiddleware);
|
||||
* scope.get("/health", (req, res) -> "ok"); // → GET /api/health
|
||||
* scope.scan("dev.example.api.handlers");
|
||||
* });
|
||||
* }</pre>
|
||||
*/
|
||||
public final class FlashScope implements FlashRegistrar {
|
||||
|
||||
private final AbstractRouter router;
|
||||
private final String namespace;
|
||||
private final ExtensionContext ctx;
|
||||
private final String namespace;
|
||||
private final FlashContext 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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
FlashScope(String namespace, FlashContext parentCtx) {
|
||||
this.namespace = namespace;
|
||||
this.ctx = parentCtx.child();
|
||||
}
|
||||
@@ -64,10 +45,7 @@ public final class FlashScope implements FlashRegistrar {
|
||||
// ── Pending flush ─────────────────────────────────────────────────────────
|
||||
|
||||
private void flushPending() {
|
||||
if (pending != null) {
|
||||
pending.ensureRegistered();
|
||||
pending = null;
|
||||
}
|
||||
if (pending != null) { pending.ensureRegistered(); pending = null; }
|
||||
}
|
||||
|
||||
private <P> RouteHandle<P> track(RouteHandle<P> handle) {
|
||||
@@ -76,12 +54,20 @@ public final class FlashScope implements FlashRegistrar {
|
||||
return handle;
|
||||
}
|
||||
|
||||
// ── FlashRegistrar — extension installation ───────────────────────────────
|
||||
// ── Scope middleware ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Installs an extension scoped to this namespace.
|
||||
* The extension registers routes and services on this scope only.
|
||||
* Adds middlewares applied to every route in this scope.
|
||||
* 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
|
||||
public FlashScope install(FlashExtension ext) {
|
||||
flushPending();
|
||||
@@ -89,128 +75,73 @@ public final class FlashScope implements FlashRegistrar {
|
||||
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> post (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.POST, path, h); }
|
||||
@Override public RouteHandle<FlashScope> put (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PUT, path, h); }
|
||||
@Override public RouteHandle<FlashScope> delete (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.DELETE, path, h); }
|
||||
@Override public RouteHandle<FlashScope> patch (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PATCH, path, h); }
|
||||
@Override public RouteHandle<FlashScope> options(String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.OPTIONS, path, h); }
|
||||
@Override public RouteHandle<FlashScope> head (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.HEAD, path, h); }
|
||||
@Override public RouteHandle<FlashScope> trace (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.TRACE, path, h); }
|
||||
@Override public RouteHandle<FlashScope> connect(String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.CONNECT, path, h); }
|
||||
@Override public RouteHandle<FlashScope> purge (String path, SimpleHandler.FunctionalHandler h) { return routeLambda(HttpMethod.PURGE, path, h); }
|
||||
@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 lambda(HttpMethod.POST, 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 lambda(HttpMethod.DELETE, 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 lambda(HttpMethod.OPTIONS, 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 lambda(HttpMethod.TRACE, 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 lambda(HttpMethod.PURGE, path, h); }
|
||||
|
||||
private RouteHandle<FlashScope> routeLambda(HttpMethod method, String path, SimpleHandler.FunctionalHandler h) {
|
||||
return track(new RouteHandle<>(this, m -> {
|
||||
String full = ns(path);
|
||||
emit(method, full, null, List.of(), m);
|
||||
router.doRegister(method, full, h, m);
|
||||
}));
|
||||
private RouteHandle<FlashScope> lambda(HttpMethod method, String path, SimpleHandler.FunctionalHandler h) {
|
||||
String full = ns(path);
|
||||
Middleware[] scopeMw = snapshotScopeMiddlewares();
|
||||
return track(new RouteHandle<>(this, mw ->
|
||||
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
|
||||
* parent app and any installed on this scope are applied. The {@link Route @Route}
|
||||
* path is prepended with this scope's namespace automatically.
|
||||
*
|
||||
* <p>Calling {@link RouteHandle#with} is optional — the route is registered
|
||||
* automatically before the next operation or when the scope consumer returns.
|
||||
* Registers a class-based handler annotated with {@link Route @Route}.
|
||||
* Not part of {@link FlashRegistrar} — scope-only convenience.
|
||||
*/
|
||||
@Override
|
||||
public RouteHandle<FlashScope> register(RequestHandler handler) {
|
||||
return track(new RouteHandle<>(this, explicit -> {
|
||||
List<Middleware> injected = ctx.processors().stream()
|
||||
.flatMap(p -> p.process(handler.getClass()).stream())
|
||||
.toList();
|
||||
Middleware[] all = injected.isEmpty()
|
||||
? explicit
|
||||
: Stream.concat(injected.stream(), Arrays.stream(explicit)).toArray(Middleware[]::new);
|
||||
Route annotation = handler.getClass().getAnnotation(Route.class);
|
||||
if (annotation != null) {
|
||||
String full = ns(annotation.path());
|
||||
emit(annotation.method(), full, handler.getClass(), injected, explicit);
|
||||
router.doRegister(annotation.method(), full, (RequestHandler) handler, all);
|
||||
}
|
||||
}));
|
||||
RouteHandle<FlashScope> register(RequestHandler handler) {
|
||||
Route ann = handler.getClass().getAnnotation(Route.class);
|
||||
if (ann == null)
|
||||
throw new InitializationException(
|
||||
handler.getClass().getName() + " is missing @Route");
|
||||
String full = ns(ann.path());
|
||||
Middleware[] scopeMw = snapshotScopeMiddlewares();
|
||||
return track(new RouteHandle<>(this, mw ->
|
||||
deferredRoutes.add(new RouteDefinition(ann.method(), full, handler, scopeMw, mw, true, ctx, namespace))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans {@code packageName} for {@link RequestHandler} subclasses annotated with
|
||||
* {@link Route @Route}. Each is instantiated via its no-arg constructor and registered
|
||||
* with this scope's namespace prefix and annotation processors applied.
|
||||
*/
|
||||
@Override
|
||||
public FlashScope scan(String packageName) {
|
||||
flushPending();
|
||||
PackageScanner.findHandlers(packageName).forEach(cls -> register(instantiate(cls)).ensureRegistered());
|
||||
PackageScanner.findHandlers(packageName).forEach(cls ->
|
||||
register(instantiate(cls)).ensureRegistered());
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FlashScope onException(AbstractRouter.ExceptionHandler h) {
|
||||
flushPending();
|
||||
router.onException(h);
|
||||
return this;
|
||||
}
|
||||
public FlashContext ctx() { return ctx; }
|
||||
|
||||
@Override
|
||||
public FlashScope onNotFound(SimpleHandler.FunctionalHandler h) {
|
||||
flushPending();
|
||||
router.onNotFound(h);
|
||||
return this;
|
||||
}
|
||||
// ── Internals (called by FlashApp.mount) ──────────────────────────────────
|
||||
|
||||
@Override
|
||||
public ExtensionContext ctx() {
|
||||
return ctx;
|
||||
}
|
||||
void flush() { flushPending(); }
|
||||
|
||||
// ── 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) {
|
||||
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) {
|
||||
try {
|
||||
return (RequestHandler) cls.getDeclaredConstructor().newInstance();
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
import dev.relism.exceptions.InitializationException;
|
||||
import dev.relism.models.RequestHandler;
|
||||
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}.
|
||||
* Finds all classes in a package that extend {@link RequestHandler} and carry {@link Route @Route}.
|
||||
* 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 {
|
||||
|
||||
@@ -22,55 +27,84 @@ final class PackageScanner {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (packageName == null || packageName.isBlank())
|
||||
throw new InitializationException("scan() called with null or blank package name");
|
||||
|
||||
String resourcePath = packageName.replace('.', '/');
|
||||
ClassLoader cl = Thread.currentThread().getContextClassLoader();
|
||||
List<Class<?>> result = new ArrayList<>();
|
||||
List<String> errors = new ArrayList<>();
|
||||
boolean packageFound = false;
|
||||
|
||||
try {
|
||||
Enumeration<URL> resources = cl.getResources(resourcePath);
|
||||
while (resources.hasMoreElements()) {
|
||||
packageFound = true;
|
||||
URL url = resources.nextElement();
|
||||
String protocol = url.getProtocol();
|
||||
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)) {
|
||||
String jarPath = url.getPath();
|
||||
// jar:file:/path/to/app.jar!/com/example → /path/to/app.jar
|
||||
String filePart = jarPath.substring(jarPath.indexOf("file:") + 5, jarPath.indexOf('!'));
|
||||
try (JarFile jar = new JarFile(filePart)) {
|
||||
scanJar(jar, resourcePath, packageName, cl, result);
|
||||
scanJar(jar, resourcePath, packageName, cl, result, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (InitializationException e) {
|
||||
throw e; // re-throw our own exceptions
|
||||
} 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;
|
||||
}
|
||||
|
||||
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();
|
||||
if (files == null) return;
|
||||
for (File file : files) {
|
||||
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())) {
|
||||
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,
|
||||
ClassLoader cl, List<Class<?>> result) {
|
||||
ClassLoader cl, List<Class<?>> result, List<String> errors) {
|
||||
Enumeration<JarEntry> entries = jar.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
String name = entries.nextElement().getName();
|
||||
if (name.startsWith(resourcePath) && name.endsWith(".class") && !isAnonymous(name)) {
|
||||
String className = name.replace('/', '.').replace(".class", "");
|
||||
tryLoad(className, cl, result);
|
||||
tryLoad(className, cl, result, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,23 +118,34 @@ final class PackageScanner {
|
||||
private static boolean isAnonymous(String fileName) {
|
||||
int dollar = fileName.lastIndexOf('$');
|
||||
if (dollar < 0) return false;
|
||||
// skip past any extra '$' (lambda desugaring may produce '$$Lambda$...')
|
||||
int next = dollar + 1;
|
||||
while (next < fileName.length() && fileName.charAt(next) == '$') next++;
|
||||
return next < fileName.length() && Character.isDigit(fileName.charAt(next));
|
||||
}
|
||||
|
||||
private static void tryLoad(String className, ClassLoader cl, List<Class<?>> result) {
|
||||
private static void tryLoad(String className, ClassLoader cl,
|
||||
List<Class<?>> result, List<String> errors) {
|
||||
try {
|
||||
Class<?> cls = cl.loadClass(className);
|
||||
if (RequestHandler.class.isAssignableFrom(cls)
|
||||
&& cls.isAnnotationPresent(Route.class)
|
||||
&& !java.lang.reflect.Modifier.isAbstract(cls.getModifiers())) {
|
||||
cls.getDeclaredConstructor(); // verify no-arg constructor exists
|
||||
result.add(cls);
|
||||
if (!RequestHandler.class.isAssignableFrom(cls)) return;
|
||||
if (java.lang.reflect.Modifier.isAbstract(cls.getModifiers())) return;
|
||||
if (!cls.isAnnotationPresent(Route.class)) return;
|
||||
|
||||
// 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}.
|
||||
*
|
||||
* <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.
|
||||
* There is zero overhead on the request hot-path.
|
||||
*
|
||||
|
||||
@@ -10,6 +10,7 @@ import lombok.Value;
|
||||
import lombok.experimental.NonFinal;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@@ -43,24 +44,42 @@ public class Request {
|
||||
@NonFinal @Setter PathParams pathParams;
|
||||
@NonFinal @Setter QueryParams queryParams;
|
||||
|
||||
private Request(RequestLine requestLine, RequestBody body) {
|
||||
this.requestLine = requestLine;
|
||||
this.body = body;
|
||||
this.pathParams = null;
|
||||
this.queryParams = null;
|
||||
/**
|
||||
* Remote socket address of the connected client. Set once at connection time from
|
||||
* {@link java.net.Socket#getRemoteSocketAddress()} — the {@link InetSocketAddress}
|
||||
* object already exists in the JDK and is passed by reference: zero allocation,
|
||||
* 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) {
|
||||
this(requestLine, RequestBody.of(body));
|
||||
this(requestLine, RequestBody.of(body), null);
|
||||
}
|
||||
|
||||
public static Request forParsed(RequestLine requestLine, InputStream stream,
|
||||
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)
|
||||
: contentLength == 0 ? RequestBody.empty()
|
||||
: /* chunked */ new RequestBody(stream, -1L, null, 0, 0);
|
||||
return new Request(requestLine, rb);
|
||||
return new Request(requestLine, rb, remoteAddress);
|
||||
}
|
||||
|
||||
// ── Request line ──────────────────────────────────────────────────────────
|
||||
@@ -126,6 +145,22 @@ public class Request {
|
||||
*/
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,130 @@
|
||||
package dev.relism.models;
|
||||
|
||||
import dev.relism.extension.FlashContext;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Base class for class-based route handlers.
|
||||
*
|
||||
* <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
|
||||
* lambda DSL ({@code server.get(path, handler)}) which wraps a {@link SimpleHandler} internally.
|
||||
* via {@link dev.relism.extension.FlashApp#register} or {@link dev.relism.extension.FlashApp#scan}.
|
||||
* 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 {
|
||||
|
||||
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:
|
||||
* return a {@link Response} to replace the whole response, any other non-null value
|
||||
|
||||
@@ -1,53 +1,24 @@
|
||||
package dev.relism.routing;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import dev.relism.models.*;
|
||||
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 "/"}).
|
||||
* Error handlers are scoped to this router.
|
||||
* Base router contract. Owns error handlers and the compile-time middleware
|
||||
* 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
|
||||
* pre-fused into a single wrapper applied to every handler registered on this router.
|
||||
* 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 {@link RequestHandler} call with zero allocation overhead.
|
||||
*
|
||||
* <p>All middleware composition happens once at boot time; the hot-path sees only a plain
|
||||
* {@link RequestHandler} call with zero allocation and zero lookup overhead.
|
||||
*
|
||||
* <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.
|
||||
* <p><b>Registration</b>: use {@link dev.relism.extension.FlashApp} — the single
|
||||
* public registration API. {@link #doRegister} is an infrastructure method.
|
||||
*/
|
||||
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) -> {
|
||||
res.setStatusCode(404);
|
||||
res.setContentType(ContentType.TEXT_HTML);
|
||||
@@ -60,113 +31,44 @@ public abstract class AbstractRouter {
|
||||
return ErrorPages.renderException(req, ex);
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructs a router with optional router-level middlewares.
|
||||
* 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 SimpleHandler getNotFoundHandler() { return notFoundHandler; }
|
||||
public ExceptionHandler getExceptionHandler() { return exceptionHandler; }
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
public void onNotFound(SimpleHandler.FunctionalHandler handler) {
|
||||
this.notFoundHandler = new SimpleHandler(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
public AbstractRouter onException(ExceptionHandler handler) {
|
||||
public void onException(ExceptionHandler handler) {
|
||||
this.exceptionHandler = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── Infrastructure registration ───────────────────────────────────────────
|
||||
// Used by FlashApp and FlashScope. Not part of the public user-facing API.
|
||||
// ── Infrastructure registration ──────────────────────────────────────────
|
||||
// Used by FlashApp at compile time. Not part of the user-facing API.
|
||||
|
||||
/**
|
||||
* Registers a lambda handler immediately with a pre-built middleware array.
|
||||
* Infrastructure method — use {@link dev.relism.extension.FlashApp} instead.
|
||||
*/
|
||||
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.
|
||||
* Registers a handler with a pre-built middleware array.
|
||||
* Compiles the middleware chain once at boot — zero overhead on hot-path.
|
||||
*/
|
||||
public AbstractRouter doRegister(HttpMethod method, String path,
|
||||
RequestHandler handler, Middleware[] 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);
|
||||
|
||||
/**
|
||||
* 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 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;
|
||||
}
|
||||
}
|
||||
+2
-6
@@ -9,8 +9,6 @@ import dev.relism.http.HttpMethod;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestHandler;
|
||||
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
|
||||
@@ -23,8 +21,7 @@ public class FastPathRouterImpl extends AbstractRouter {
|
||||
private volatile FastPathRouter<ByteView, RequestHandler> router;
|
||||
private String[] cachedParamNames;
|
||||
|
||||
public FastPathRouterImpl(Middleware... middlewares) { super(middlewares); }
|
||||
public FastPathRouterImpl() { super(); }
|
||||
public FastPathRouterImpl() {}
|
||||
|
||||
private static final class FastPathRouterContext {
|
||||
private static final ThreadLocal<MatchResult<RequestHandler>> RESULT_HOLDER =
|
||||
@@ -43,8 +40,7 @@ public class FastPathRouterImpl extends AbstractRouter {
|
||||
|
||||
@Override
|
||||
protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) {
|
||||
String fullPath = PathUtils.join(namespace, path);
|
||||
builder.add(StringRouteParser.parse(method.name() + fullPath), handler);
|
||||
builder.add(StringRouteParser.parse(method.name() + path), handler);
|
||||
this.router = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -8,22 +8,17 @@ import dev.relism.models.Response;
|
||||
import dev.relism.models.SimpleHandler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AbstractRouterTest {
|
||||
|
||||
// A minimal concrete router for testing base-class functionality
|
||||
static class DummyRouter extends AbstractRouter {
|
||||
RequestHandler lastAddedHandler;
|
||||
HttpMethod lastAddedMethod;
|
||||
String lastAddedPath;
|
||||
|
||||
@Override
|
||||
public RequestHandler route(Request request) {
|
||||
return null;
|
||||
}
|
||||
public RequestHandler route(Request request) { return null; }
|
||||
|
||||
@Override
|
||||
protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) {
|
||||
@@ -34,31 +29,15 @@ class AbstractRouterTest {
|
||||
}
|
||||
}
|
||||
|
||||
// --- namespace ---
|
||||
|
||||
@Test
|
||||
void setNamespace_updatesStringAndBytes() {
|
||||
void doRegister_sanitizesPathAndWrapsHandler() {
|
||||
DummyRouter router = new DummyRouter();
|
||||
assertEquals("/", router.getNamespace());
|
||||
|
||||
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]);
|
||||
router.doRegister(HttpMethod.GET, "users/", new SimpleHandler((req, res) -> "OK"), new Middleware[0]);
|
||||
assertEquals(HttpMethod.GET, router.lastAddedMethod);
|
||||
assertEquals("/users", router.lastAddedPath);
|
||||
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("/delete", router.lastAddedPath);
|
||||
}
|
||||
@@ -69,17 +48,13 @@ class AbstractRouterTest {
|
||||
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
|
||||
void doRegister_annotatedHandler_addsRoute() {
|
||||
void doRegister_classBasedHandler_addsRoute() {
|
||||
DummyRouter router = new DummyRouter();
|
||||
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("/profile", router.lastAddedPath);
|
||||
@@ -87,27 +62,16 @@ class AbstractRouterTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void doRegister_unannotatedHandler_doesNothing() {
|
||||
void defaultNotFoundHandler_returns404() throws Exception {
|
||||
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());
|
||||
|
||||
router.onNotFound((req, resp) -> "Custom 404");
|
||||
assertEquals("Custom 404", router.getNotFoundHandler().handle(null, res));
|
||||
assertEquals("Custom 404", router.getNotFoundHandler().handle(null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultExceptionHandler_canBeOverridden() throws Exception {
|
||||
void defaultExceptionHandler_canBeOverridden() {
|
||||
DummyRouter router = new DummyRouter();
|
||||
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")));
|
||||
}
|
||||
}
|
||||
+6
-10
@@ -5,6 +5,7 @@ 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.Middleware;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -14,9 +15,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FastPathRouterImplTest {
|
||||
|
||||
private static final Middleware[] NO_MIDDLEWARE = new Middleware[0];
|
||||
|
||||
// --- helpers ---
|
||||
private static final Middleware[] NO_MW = new Middleware[0];
|
||||
|
||||
private Request mockRequest(HttpMethod method, String path) {
|
||||
byte[] bytes = path.getBytes(StandardCharsets.UTF_8);
|
||||
@@ -29,14 +28,11 @@ class FastPathRouterImplTest {
|
||||
return new Request(line, new byte[0]);
|
||||
}
|
||||
|
||||
// --- route ---
|
||||
|
||||
@Test
|
||||
void route_lazyCompilationAndMatch() throws Exception {
|
||||
FastPathRouterImpl router = new FastPathRouterImpl();
|
||||
|
||||
router.doRegister(HttpMethod.GET, "/a", (req, res) -> "A", NO_MIDDLEWARE);
|
||||
router.doRegister(HttpMethod.POST, "/b", (req, res) -> "B", NO_MIDDLEWARE);
|
||||
router.doRegister(HttpMethod.GET, "/a", new SimpleHandler((req, res) -> "A"), NO_MW);
|
||||
router.doRegister(HttpMethod.POST, "/b", new SimpleHandler((req, res) -> "B"), NO_MW);
|
||||
|
||||
RequestHandler res1 = router.route(mockRequest(HttpMethod.GET, "/a"));
|
||||
assertNotNull(res1);
|
||||
@@ -50,7 +46,7 @@ class FastPathRouterImplTest {
|
||||
@Test
|
||||
void route_noMatch_returnsNull() {
|
||||
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.POST, "/a")));
|
||||
@@ -60,7 +56,7 @@ class FastPathRouterImplTest {
|
||||
void route_extractsPathParams() throws Exception {
|
||||
FastPathRouterImpl router = new FastPathRouterImpl();
|
||||
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");
|
||||
RequestHandler handler = router.route(request);
|
||||
|
||||
@@ -58,6 +58,16 @@
|
||||
<artifactId>flash-ext-routeviewer</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</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>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>fpr-core</artifactId>
|
||||
|
||||
Reference in New Issue
Block a user