preparing for another refactoring...

This commit is contained in:
Relism
2026-03-29 23:16:41 +02:00
parent 2edd68b0aa
commit b5d4481502
69 changed files with 4329 additions and 1076 deletions
@@ -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.)
}
}
@@ -1,77 +0,0 @@
package dev.relism.ext.jackson;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.exceptions.HttpException;
import dev.relism.http.ContentType;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import dev.relism.models.Response;
/**
* Base class for handlers that need JSON I/O via Jackson.
*
* <p>The {@link ObjectMapper} is injected by {@link JacksonExtension#install} once at
* startup — all subclasses share the same instance. If no {@code JacksonExtension} is
* installed the field remains {@code null} and the first call to {@link #bodyAs} or
* {@link #json} will throw an {@link IllegalStateException}.
*
* <pre>{@code
* @Route(method = HttpMethod.POST, path = "/api/blogs")
* public class CreateBlog extends JacksonHandler {
* public Object handle(Request req, Response res) throws Exception {
* CreateBlogRequest body = bodyAs(req, CreateBlogRequest.class);
* Blog created = service.create(body);
* res.setStatusCode(201);
* return json(res, created);
* }
* }
* }</pre>
*/
public abstract class JacksonHandler extends RequestHandler {
/**
* Shared mapper set by {@link JacksonExtension}. Package-visible so the extension
* can assign it; {@code volatile} ensures visibility across virtual threads.
*/
static volatile ObjectMapper mapper;
/**
* Deserializes the request body bytes into {@code type}.
* Wraps Jackson parse errors as {@link HttpException} 400.
*/
protected <T> T bodyAs(Request req, Class<T> type) throws Exception {
requireMapper();
try {
return mapper.readValue(req.body().bytes(), type);
} catch (JsonProcessingException e) {
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
}
}
/**
* Serializes {@code obj} to JSON, sets {@code Content-Type: application/json},
* and returns the JSON string as the response body.
*/
protected String json(Response res, Object obj) throws Exception {
requireMapper();
res.setContentType(ContentType.JSON);
return mapper.writeValueAsString(obj);
}
/**
* Like {@link #json} but serializes only fields visible under the given
* {@code view} class (see Jackson {@code @JsonView}).
*/
protected String jsonView(Response res, Object obj, Class<?> view) throws Exception {
requireMapper();
res.setContentType(ContentType.JSON);
return mapper.writerWithView(view).writeValueAsString(obj);
}
private static void requireMapper() {
if (mapper == null)
throw new IllegalStateException(
"JacksonExtension not installed: call FlashApp.install(new JacksonExtension()) first");
}
}
@@ -0,0 +1,126 @@
package dev.relism.ext.jackson;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.exceptions.HttpException;
import dev.relism.http.ContentType;
import dev.relism.models.Request;
import dev.relism.models.Response;
/**
* Thread-safe JSON toolbox. Single point of access for all JSON I/O operations
* within a Flash application.
*
* <p>Retrieve once at boot time via {@code require(Json.class)} inside
* {@code onInit()}, cache in a private field, and call on the hot path
* with zero lookup or allocation overhead:
*
* <pre>{@code
* @Route(method = HttpMethod.POST, path = "/api/items")
* public class CreateItemHandler extends RequestHandler {
*
* private Json json;
*
* @Override
* protected void onInit() {
* json = require(Json.class);
* }
*
* public Object handle(Request req, Response res) throws Exception {
* CreateItemRequest body = json.body(req, CreateItemRequest.class);
* return json.write(res, itemService.create(body));
* }
* }
* }</pre>
*
* <p>The underlying {@link ObjectMapper} is shared across all handlers in the same
* scope (one instance per app / per child scope). Jackson's {@code ObjectMapper}
* is fully thread-safe after configuration — no synchronization is needed.
*
* <p>Install via {@link JacksonExtension} before calling {@code scan()} or
* {@code register()}.
*/
public final class Json {
private final ObjectMapper mapper;
/** Package-private — constructed exclusively by {@link JacksonExtension}. */
Json(ObjectMapper mapper) {
this.mapper = mapper;
}
// ── Input ─────────────────────────────────────────────────────────────────
/**
* Deserializes the full request body into an instance of {@code type}.
*
* <p>Reads {@code req.body().bytes()} in one shot. For streaming bodies
* use {@link #bodyFrom(Request, Class)} instead.
*
* @throws HttpException 400 if the body cannot be parsed as {@code type}
*/
public <T> T body(Request req, Class<T> type) throws Exception {
try {
return mapper.readValue(req.body().bytes(), type);
} catch (JsonProcessingException e) {
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
}
}
/**
* Deserializes the request body via the raw {@link java.io.InputStream},
* avoiding the intermediate {@code byte[]} allocation. Prefer this for
* large bodies or when allocation budget is tight.
*
* @throws HttpException 400 on parse failure
*/
public <T> T bodyFrom(Request req, Class<T> type) throws Exception {
try {
return mapper.readValue(req.body().stream(), type);
} catch (JsonProcessingException e) {
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
}
}
// ── Output ────────────────────────────────────────────────────────────────
/**
* Serializes {@code obj} to a JSON string and sets
* {@code Content-Type: application/json} on the response.
*
* <p>The returned string is used as the response body by the Flash runtime.
*/
public String write(Response res, Object obj) throws Exception {
res.setContentType(ContentType.JSON);
return mapper.writeValueAsString(obj);
}
/**
* Like {@link #write(Response, Object)} but also sets an explicit HTTP status code.
*/
public String write(Response res, int status, Object obj) throws Exception {
res.status(status);
res.setContentType(ContentType.JSON);
return mapper.writeValueAsString(obj);
}
/**
* Like {@link #write} but applies a Jackson {@code @JsonView} filter,
* restricting serialization to fields visible under {@code view}.
*/
public String writeView(Response res, Object obj, Class<?> view) throws Exception {
res.setContentType(ContentType.JSON);
return mapper.writerWithView(view).writeValueAsString(obj);
}
// ── Escape hatch ──────────────────────────────────────────────────────────
/**
* Returns the underlying {@link ObjectMapper} for advanced operations
* (custom serialization, schema generation, etc.) not covered by the
* methods above.
*/
public ObjectMapper mapper() {
return mapper;
}
}
@@ -0,0 +1,65 @@
# flash-ext-limiter
Rate limiting for the Flash HTTP server. Zero-allocation hot-path, lock-free counters,
pluggable key resolvers, and two built-in algorithms.
## What it provides
| Component | Description |
|---|---|
| `@Limit` | Annotation for class-based handlers — processed once at boot |
| `Guard` | Programmatic middleware factory for lambda routes |
| `LimiterConfig` | Resolver registry — map string names to key-extraction lambdas |
| `FIXED_WINDOW` | Clock-aligned counter reset; minimal memory |
| `TOKEN_BUCKET` | Continuous refill; absorbs bursts smoothly |
## Dependency
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-limiter</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
```
## Quick start
```java
// Default install — only the built-in "ip" resolver available
FlashApp.create(8080)
.install(new LimiterExtension())
.scan("com.example.handlers");
```
```java
// With custom resolvers
LimiterConfig conf = new LimiterConfig()
.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
FlashApp.create(8080)
.install(new LimiterExtension(conf))
.scan("com.example.handlers");
```
## Installation order
Install `LimiterExtension` **before** authentication extensions. Rate-limit checks
then short-circuit over-limit requests before expensive token validation runs.
```java
app.install(new LimiterExtension(conf)) // ← first
.install(new OidcExtension(oidcConf)) // ← second
.scan("com.example");
```
## Docs
| File | Contents |
|---|---|
| [key-resolvers.md](key-resolvers.md) | Resolver registration, built-in defaults, custom logic |
| [annotation.md](annotation.md) | `@Limit` reference — all fields and examples |
| [guard.md](guard.md) | `Guard` for lambda routes — all overloads |
| [strategies.md](strategies.md) | `FIXED_WINDOW` vs `TOKEN_BUCKET` — algorithm reference |
| [http-headers.md](http-headers.md) | HTTP compliance — headers and 429 response |
@@ -0,0 +1,132 @@
# @Limit annotation
Applies a rate limit to a **class-based** `RequestHandler`. The annotation is read once
per handler class at boot by the `LimiterExtension` annotation processor — zero overhead
at request time.
## Declaration
```java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Limit {
String key() default "ip";
int requests();
long window();
TimeUnit windowUnit() default TimeUnit.SECONDS;
LimitStrategy strategy() default LimitStrategy.FIXED_WINDOW;
}
```
## Fields
| Field | Type | Default | Description |
|---|---|---|---|
| `key` | `String` | `"ip"` | Name of the key resolver registered in `LimiterConfig` |
| `requests` | `int` | — | Maximum requests allowed per window (required) |
| `window` | `long` | — | Window duration in `windowUnit` units (required) |
| `windowUnit` | `TimeUnit` | `SECONDS` | Time unit for `window` |
| `strategy` | `LimitStrategy` | `FIXED_WINDOW` | Rate-limit algorithm |
## Basic usage
### 100 requests per second per IP (default)
```java
@Route(method = HttpMethod.GET, path = "/api/search")
@Limit(requests = 100, window = 1)
public class SearchHandler extends RequestHandler {
public Object handle(Request req, Response res) {
return searchService.query(req.query("q"));
}
}
```
### 20 requests per minute per authenticated user
```java
@Route(method = HttpMethod.POST, path = "/api/report")
@Limit(key = "auth_user", requests = 20, window = 1, windowUnit = TimeUnit.MINUTES)
@Authenticated
public class ReportHandler extends RequestHandler {
public Object handle(Request req, Response res) { ... }
}
```
Order of annotation processors: register `LimiterExtension` before `OidcExtension`
so the rate-limit middleware wraps the outer layer of the chain and fires before auth.
### Token bucket — absorb bursts
```java
@Route(method = HttpMethod.POST, path = "/api/upload")
@Limit(
key = "api_key",
requests = 50,
window = 1,
windowUnit = TimeUnit.MINUTES,
strategy = LimitStrategy.TOKEN_BUCKET
)
public class UploadHandler extends RequestHandler { ... }
```
### Large window — 1000 requests per hour
```java
@Route(method = HttpMethod.GET, path = "/api/export")
@Limit(requests = 1000, window = 1, windowUnit = TimeUnit.HOURS)
public class ExportHandler extends RequestHandler { ... }
```
### Strict per-second limit on a public endpoint
```java
@Route(method = HttpMethod.GET, path = "/api/prices")
@Limit(requests = 10, window = 1, windowUnit = TimeUnit.SECONDS)
public class PriceHandler extends RequestHandler { ... }
```
## Combining @Limit with other annotations
`@Limit` composes naturally with `@Authenticated`, `@RolesAllowed`, and `@ApiOperation`.
Each annotation is processed by its own processor; Flash collects all middleware and
composes them in processor registration order.
```java
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
@Limit(key = "auth_user", requests = 5, window = 1, windowUnit = TimeUnit.MINUTES)
@RolesAllowed("admin")
@ApiOperation(summary = "Delete a user", tags = "admin")
public class DeleteUserHandler extends RequestHandler { ... }
```
Execution chain (outermost → handler):
`LimiterMiddleware → OidcRolesMiddleware → DeleteUserHandler`
## Fail-fast at boot
If `key` names a resolver not registered in `LimiterConfig`, the server refuses to start:
```
dev.relism.exceptions.InitializationException:
Rate-limit resolver "auth_user" is not registered.
Call LimiterConfig.registerResolver("auth_user", req -> ...) before install.
```
There is no silent fallback — a misconfigured rate limit is treated as a hard error.
## What happens on violation
```
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711750860
Retry-After: 1
Content-Type: text/plain
Too Many Requests
```
The handler body is never invoked. See [http-headers.md](http-headers.md) for the full
header reference.
@@ -0,0 +1,144 @@
# Guard — programmatic rate limiting for lambda routes
`Guard` is the rate-limit API for lambda (inline) route registrations. It produces
a `Middleware` that is composed once at route-wiring time — the resolver lambda is
captured directly into the closure, with no map lookup on the request hot-path.
## Obtaining Guard
`Guard` is provided in the `FlashContext` after `LimiterExtension` is installed:
```java
Guard guard = app.ctx().require(Guard.class);
```
Or inside another extension:
```java
public void install(FlashRegistrar app, FlashContext ctx) {
Guard guard = ctx.require(Guard.class);
// ...
}
```
## API
```java
// Fixed window (default strategy)
Middleware limit(String resolverKey, int requests, long window, TimeUnit unit)
// Explicit strategy
Middleware limit(String resolverKey, int requests, long window, TimeUnit unit, LimitStrategy strategy)
```
Both overloads:
- Resolve the named key lambda **once** at call time (fail-fast if unknown).
- Return a stateless `Middleware` whose closure captures the lambda and `LimitConfig` directly.
- Share the `BucketStore` with all other rules registered through this `LimiterExtension` instance.
## Examples
### Simple per-IP limit on a lambda route
```java
Guard guard = app.ctx().require(Guard.class);
app.get("/api/search", (req, res) -> searchService.query(req.query("q")))
.with(guard.limit("ip", 100, 1, TimeUnit.SECONDS));
```
### Per authenticated user — token bucket
```java
app.post("/api/export", (req, res) -> exportService.run(req))
.with(guard.limit("auth_user", 10, 1, TimeUnit.MINUTES, LimitStrategy.TOKEN_BUCKET));
```
### Chaining with other middleware
`Guard.limit(...)` returns a plain `Middleware`, so it composes with `Middleware.of()`
and `.andThen()` exactly like any other middleware:
```java
Middleware secured = Middleware.of(
guard.limit("ip", 200, 1, TimeUnit.SECONDS), // ← outermost: runs first
oidc.protect()
);
app.get("/dashboard", handler).with(secured);
```
Or with `.andThen()` for two middlewares:
```java
app.get("/dashboard", handler)
.with(guard.limit("ip", 200, 1, TimeUnit.SECONDS).andThen(oidc.protect()));
```
### Different limits on the same path by method
```java
// Read: 500/s; Write: 20/s
app.get("/api/items", readHandler) .with(guard.limit("ip", 500, 1, TimeUnit.SECONDS));
app.post("/api/items", writeHandler).with(guard.limit("ip", 20, 1, TimeUnit.SECONDS));
```
Each `.with(guard.limit(...))` call creates an independent bucket store key namespace —
GET and POST requests to `/api/items` share the same IP bucket only if you share the same
`Middleware` instance. Using two `guard.limit(...)` calls creates **two independent buckets**.
### Reusing a middleware instance across routes
To share a single bucket pool across multiple routes (treating them as one combined limit):
```java
Middleware sharedIpLimit = guard.limit("ip", 1000, 1, TimeUnit.MINUTES);
app.get("/api/items", handler1).with(sharedIpLimit);
app.get("/api/items/{id}", handler2).with(sharedIpLimit);
app.post("/api/items", handler3).with(sharedIpLimit);
```
All three routes now draw from the same per-IP bucket — 1000 combined requests per minute.
### Inside an extension
```java
public class MyApiExtension implements FlashExtension {
public void install(FlashRegistrar app, FlashContext ctx) {
Guard guard = ctx.require(Guard.class); // LimiterExtension must be installed first
Middleware ipLimit = guard.limit("ip", 60, 1, TimeUnit.SECONDS);
app.get("/api/status", statusHandler) .with(ipLimit);
app.get("/api/metrics", metricsHandler).with(ipLimit);
}
}
```
### Large window
```java
app.get("/api/export", exportHandler)
.with(guard.limit("api_key", 50, 24, TimeUnit.HOURS));
```
## Fail-fast
If the resolver name is not registered, `guard.limit(...)` throws immediately
(at wiring time, not at request time):
```
InitializationException: Rate-limit resolver "auth_user" is not registered.
```
## Comparison: Guard vs @Limit
| | `@Limit` | `Guard.limit(...)` |
|---|---|---|
| Route style | Class-based `RequestHandler` | Lambda `(req, res) -> ...` |
| Configuration | Annotation fields | Method arguments |
| Where resolved | `AnnotationProcessor` at `scan()` | `guard.limit(...)` call at wiring |
| Hot-path overhead | Zero | Zero |
| Fail-fast | Yes | Yes |
| Composable with `Middleware.of()` | Via annotation processor order | Yes, directly |
@@ -0,0 +1,122 @@
# HTTP headers and 429 response
The extension injects standard rate-limit headers on **every** request — both allowed
and rejected. Clients can use these headers to implement back-off logic without waiting
for a 429.
## Response headers
| Header | Type | Description |
|---|---|---|
| `X-RateLimit-Limit` | integer | Maximum requests allowed in the current window |
| `X-RateLimit-Remaining` | integer | Requests remaining in the current window (≥ 0) |
| `X-RateLimit-Reset` | Unix timestamp (s) | When the quota resets or the next token arrives |
| `Retry-After` | seconds | **Only on 429** — how long to wait before retrying (≥ 1) |
### Example — allowed request
```
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 73
X-RateLimit-Reset: 1711750860
Content-Type: application/json
```
### Example — rejected request (429)
```
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711750860
Retry-After: 1
Content-Type: text/plain
Too Many Requests
```
## Header semantics by strategy
### FIXED_WINDOW
| Header | Value |
|---|---|
| `X-RateLimit-Reset` | Unix timestamp of the **next window start** (aligned to clock) |
| `Retry-After` | Seconds until `X-RateLimit-Reset` (minimum 1) |
At a 1-second window boundary `Retry-After` will typically be `1`.
### TOKEN_BUCKET
| Header | Value |
|---|---|
| `X-RateLimit-Remaining` | Current token count (may increase between requests due to refill) |
| `X-RateLimit-Reset` | Estimated Unix timestamp when the **next token arrives** |
| `Retry-After` | Milliseconds-precise estimate converted to seconds (minimum 1) |
Because the token bucket refills continuously, `X-RateLimit-Reset` is a near-future
timestamp rather than an aligned window boundary.
## Retry-After precision
`Retry-After` is computed as:
```
retryAfter = max(1, X-RateLimit-Reset - currentTimeSeconds)
```
The minimum value is always `1` second — RFC 7231 discourages `Retry-After: 0` as it
encourages instant retry loops.
## Client-side back-off example (Java)
```java
HttpResponse<String> res = client.send(request, BodyHandlers.ofString());
if (res.statusCode() == 429) {
String retryAfter = res.headers().firstValue("Retry-After").orElse("1");
long waitMs = Long.parseLong(retryAfter) * 1000L;
Thread.sleep(waitMs);
// retry...
}
```
## Client-side back-off example (JavaScript fetch)
```js
const res = await fetch('/api/search?q=flash');
if (res.status === 429) {
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '1', 10);
await new Promise(r => setTimeout(r, retryAfter * 1000));
// retry...
}
```
## Monitoring / alerting
`X-RateLimit-Remaining` can be scraped by a metrics agent to track approaching limits
before they hit 429:
- `remaining / limit < 0.1` → warning (less than 10% quota left)
- `status == 429` → rate-limit violation counter increment
If `flash-ext-limiter` is used together with a future metrics extension, the 429 rate
per resolver key is a natural signal for abuse detection or auto-scaling.
## Header injection timing
Headers are injected **before** calling `next.handle(req, res)` on allowed requests,
and **instead of** calling it on rejected requests. This means:
- Handlers cannot accidentally overwrite `X-RateLimit-*` headers (they are set first,
but handlers that call `res.header(...)` with the same name will add a second value —
avoid this by not setting these headers manually).
- On 429, the handler body is never executed — no side effects occur.
## Integration with Swagger UI (flash-ext-openapi)
Rate-limit headers are not currently injected into the OpenAPI spec. If you want to
document them, add them manually via `@ApiOperation` on the handler class using the
response headers section of the OpenAPI spec.
@@ -0,0 +1,143 @@
# Key Resolvers
A **key resolver** is a lambda `Request → String` that extracts the partition key used
to identify who a rate limit applies to. Each unique key value gets its own independent
bucket — so `"ip"` limits per client address, `"auth_user"` limits per logged-in user, etc.
## Built-in resolver: `"ip"`
Always present. Cannot be removed; can be overridden with `registerResolver("ip", ...)`.
Resolution order:
1. `X-Forwarded-For` header — first address in the comma-separated list (client behind proxy)
2. `X-Real-IP` header — single forwarded IP (nginx `proxy_set_header X-Real-IP`)
3. `req.remoteAddress().getAddress().getHostAddress()` — direct socket address, zero allocation
(the `InetSocketAddress` already exists from `ServerSocket.accept()`; only `getHostAddress()`
allocates a String, and only when the first two headers are absent)
4. `"unknown"` — only if `remoteAddress()` is null (test-constructed requests)
```java
// Override the built-in "ip" resolver to trust only the last hop in X-Forwarded-For
conf.registerResolver("ip", req -> {
String xff = req.header("X-Forwarded-For");
if (xff != null) {
String[] parts = xff.split(",");
return parts[parts.length - 1].strip(); // last = most recent proxy
}
return req.header("X-Real-IP") != null ? req.header("X-Real-IP").strip() : "unknown";
});
```
## Registering custom resolvers
```java
LimiterConfig conf = new LimiterConfig();
```
### By authenticated user (OIDC / ClaimsHolder)
```java
conf.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
```
Requests from unauthenticated users share the `"anonymous"` bucket. If you want
unauthenticated requests to be unlimited, pair this resolver with `@Limit` only on
handlers that are already protected by `@Authenticated`.
### By API key header
```java
conf.registerResolver("api_key", req -> {
String key = req.header("X-Api-Key");
return key != null ? key : "none";
});
```
### By tenant (multi-tenant SaaS)
```java
conf.registerResolver("tenant", req -> {
// Extract from subdomain: acme.api.example.com → "acme"
String host = req.header("Host");
if (host == null) return "unknown";
int dot = host.indexOf('.');
return dot > 0 ? host.substring(0, dot) : host;
});
```
### By IP + path (per-endpoint per-IP)
Combines two dimensions into a single key string:
```java
conf.registerResolver("ip_path", req -> {
String ip = req.header("X-Forwarded-For");
if (ip == null) ip = "unknown";
int comma = ip.indexOf(',');
if (comma > 0) ip = ip.substring(0, comma).strip();
return ip + "|" + req.path();
});
```
### Composite: role-based bucket size
One resolver, two different `@Limit` thresholds on two handler classes. The resolver
returns the same key for the same user regardless of endpoint; the limit is set per handler.
```java
conf.registerResolver("auth_user", req ->
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anon");
```
```java
@Limit(key = "auth_user", requests = 1000, window = 1) // privileged endpoint
public class AdminReportHandler extends RequestHandler { ... }
@Limit(key = "auth_user", requests = 20, window = 1) // public endpoint
public class PublicSearchHandler extends RequestHandler { ... }
```
The two handlers maintain **independent buckets** for the same user — each `@Limit`
annotation gets its own `BucketStore`.
## Resolver contract
```java
@FunctionalInterface
public interface KeyResolver {
String resolve(Request req); // must never return null; return "unknown" as fallback
}
```
- Must not return `null` — a null key will throw `NullPointerException` inside `ConcurrentHashMap`.
- Must be **thread-safe** — called concurrently from virtual threads.
- Should be **fast** — it runs on every request for every rate-limited route.
- No state should be mutated — treat `Request` as read-only.
## Fail-fast validation
If a `@Limit` annotation or `guard.limit(...)` call references a resolver name that was never
registered, the server **refuses to start** with `InitializationException`:
```
InitializationException: Rate-limit resolver "auth_user" is not registered.
Call LimiterConfig.registerResolver("auth_user", req -> ...) before install.
```
This check happens at boot time (annotation processor / Guard wiring), not at request time.
## Registration API
```java
LimiterConfig conf = new LimiterConfig()
.registerResolver("auth_user", req -> ...)
.registerResolver("tenant", req -> ...)
.registerResolver("api_key", req -> ...);
app.install(new LimiterExtension(conf));
```
`registerResolver` returns `this` for fluent chaining. Calling it with an existing name
**replaces** the previous resolver — this is how you override the built-in `"ip"` resolver.
@@ -0,0 +1,178 @@
# Rate-limit strategies
Two algorithms are built in. Both are lock-free (CAS-only), operate on pre-allocated
`Bucket` state, and write results into a caller-supplied `long[2]` — zero per-request allocation.
## FIXED_WINDOW
```java
@Limit(strategy = LimitStrategy.FIXED_WINDOW, ...) // default, can be omitted
guard.limit("ip", 100, 1, TimeUnit.SECONDS) // default
```
### How it works
The request counter resets to zero at each clock-aligned window boundary.
```
window 1 window 2 window 3
|────────────────|────────────────|────────────────|
cnt: 0 1 2 … N cnt: 0 1 2 … N cnt: 0 1 2 … N
```
With `requests = 100, window = 1s`:
- Requests 1100 in a given second → allowed
- Request 101+ in that second → 429, allowed again at second +1
### Implementation
All state is packed into a single `AtomicLong` (`Bucket.slot0`):
```
high 32 bits = reduced epoch = (currentTimeMs / windowMs) & 0xFFFFFFFF
low 32 bits = request count in the current window
```
One CAS operation per request. At a window boundary the same CAS atomically resets the
counter to 1. No locks, no additional fields.
### Burst behaviour
Because the window is fixed to the clock, a burst can occur at the boundary:
up to `N` requests at the end of window 1 followed immediately by `N` requests at the
start of window 2 → `2N` requests in a short interval.
```
window 1 │ window 2
────────────┼────────────
99 100 101 │ 1 2 3 4
↑ reset: 101 → 429, then 1 is allowed
```
If burst tolerance is unacceptable, use `TOKEN_BUCKET`.
### When to use
- Simple API rate limiting where occasional boundary bursts are acceptable.
- Scenarios where a hard "N requests per clock second/minute" guarantee matters.
- When you want minimal per-bucket memory (one `AtomicLong`, `Bucket.slot1` unused).
---
## TOKEN_BUCKET
```java
@Limit(strategy = LimitStrategy.TOKEN_BUCKET, ...)
guard.limit("ip", 100, 1, TimeUnit.SECONDS, LimitStrategy.TOKEN_BUCKET)
```
### How it works
The bucket holds up to `requests` tokens and refills at a continuous rate of
`requests / window` tokens per millisecond. Each request consumes one token.
A client that was idle accumulates tokens and can fire a burst, but sustained
excess traffic drains the bucket and triggers 429s.
```
tokens
N ─┐ ┌──── refill slope ────┐
│ │ │
0 └───────────┘ ←─ burst consumed ──→│
burst here 429s during drain recovery
```
### Refill rate
`refillPerMs = (requests × 1000) / windowMs` (integer, minimum 1)
For `requests = 100, window = 1s`:
- Refill rate: 100 tokens/s = 1 token/10 ms
- Max capacity: 100 tokens
- A client idle for 500 ms accumulates 50 tokens and can fire 50 requests instantly.
### Implementation
- `Bucket.slot0` — current tokens × 1000 (fixed-point, avoids floating-point math)
- `Bucket.slot1` — last-refill timestamp in ms (0 = uninitialised → bucket starts full)
One CAS loop on `slot0` per request; `slot1` updated best-effort after CAS success.
The bounded inaccuracy from the non-atomic dual update is at most a few nanoseconds —
negligible and self-correcting for rate limiting.
### Bucket starts full
On the very first request, `slot1 == 0`. The strategy treats this as "one full window
elapsed" → `currentTokens = max`. The bucket starts at capacity; no warm-up needed.
### When to use
- APIs where clients legitimately batch requests (analytics, bulk imports).
- Endpoints where smooth throughput matters more than hard per-second guarantees.
- Any scenario where `FIXED_WINDOW` boundary bursts would be problematic.
---
## Comparison
| | `FIXED_WINDOW` | `TOKEN_BUCKET` |
|---|---|---|
| Algorithm | Aligned counter reset | Continuous token refill |
| Burst handling | Allows 2× limit at boundaries | Absorbs bursts up to bucket capacity |
| Memory per bucket | 1 × `AtomicLong` used | 2 × `AtomicLong` used |
| Clock alignment | Yes (predictable resets) | No (smooth) |
| Typical use case | Simple request quotas | APIs with legitimate burst patterns |
| CAS operations per request | 1 (usually) | 1 (usually) |
Both strategies use the same `Bucket` type. Both are lock-free and allocation-free after
the bucket is first created.
---
## Adding a custom strategy
Implement `RateLimitStrategy` and wrap it in a `LimitStrategy` enum constant:
```java
// 1. Implement the strategy
public final class SlidingWindowStrategy implements RateLimitStrategy {
@Override
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
// ... lock-free implementation using bucket.slot0 / slot1
return allowed;
}
}
// 2. Add to the enum
public enum LimitStrategy {
FIXED_WINDOW { ... },
TOKEN_BUCKET { ... },
SLIDING_WINDOW {
@Override
public RateLimitStrategy create() { return new SlidingWindowStrategy(); }
};
public abstract RateLimitStrategy create();
}
```
The new strategy is immediately available to `@Limit(strategy = LimitStrategy.SLIDING_WINDOW)`
and `guard.limit("ip", 100, 1, SECONDS, LimitStrategy.SLIDING_WINDOW)`.
### Strategy contract
```java
public interface RateLimitStrategy {
/**
* @param bucket pre-allocated per-key state (never null)
* @param cfg immutable rule config (limit, windowMs)
* @param out out[0] = remaining, out[1] = reset epoch-seconds
* @return true = allowed, false = rejected (429)
*/
boolean check(Bucket bucket, LimitConfig cfg, long[] out);
}
```
Requirements for custom implementations:
- **Lock-free** — use `AtomicLong.compareAndSet`; no `synchronized` or `ReentrantLock`.
- **Stateless** — all mutable state must live in `Bucket.slot0` / `Bucket.slot1`.
- **No allocation** — `out[]` is the only output channel; do not create objects on the hot path.
- **Thread-safe** — called concurrently from many virtual threads.
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-limiter</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,22 @@
package dev.relism.ext.limiter;
import java.util.concurrent.atomic.AtomicLong;
/**
* Pre-allocated per-key rate limit state. Holds two {@link AtomicLong} slots whose
* semantics are strategy-specific:
*
* <ul>
* <li><b>FIXED_WINDOW</b>: {@code slot0} = packed {@code (epoch << 32 | count)},
* {@code slot1} unused.</li>
* <li><b>TOKEN_BUCKET</b>: {@code slot0} = tokens × 1000 (scaled),
* {@code slot1} = last-refill timestamp (ms since epoch).</li>
* </ul>
*
* <p>Buckets are created once per unique key (via {@link BucketStore}) and reused
* for the lifetime of the server — zero allocation on the warm path.
*/
public final class Bucket {
public final AtomicLong slot0 = new AtomicLong(0L);
public final AtomicLong slot1 = new AtomicLong(0L);
}
@@ -0,0 +1,28 @@
package dev.relism.ext.limiter;
import java.util.concurrent.ConcurrentHashMap;
/**
* Thread-safe store of pre-allocated {@link Bucket} instances keyed by partition key.
*
* <p>On the warm path (key already seen), {@link #get} performs a single
* {@link ConcurrentHashMap} lookup — no allocation. On the cold path (new key),
* {@code computeIfAbsent} allocates exactly one {@link Bucket} and inserts it.
*
* <p>Buckets accumulate indefinitely; for workloads with unbounded unique keys
* (e.g. one-shot crawlers), consider periodic store replacement or a bounded
* LRU map implementation.
*/
public final class BucketStore {
private final ConcurrentHashMap<String, Bucket> map = new ConcurrentHashMap<>();
/**
* Returns the bucket for {@code key}, creating one if absent.
* Two threads racing on the same new key are guaranteed to receive the same bucket instance.
*/
public Bucket get(String key) {
Bucket b = map.get(key);
return b != null ? b : map.computeIfAbsent(key, k -> new Bucket());
}
}
@@ -0,0 +1,69 @@
package dev.relism.ext.limiter;
import dev.relism.routing.Middleware;
import java.util.concurrent.TimeUnit;
/**
* Manual rate-limit guard for lambda routes.
*
* <p>Available via {@link dev.relism.extension.FlashContext}:
* <pre>{@code
* Guard guard = ctx.require(Guard.class);
* }</pre>
*
* <p>{@link #limit} creates a {@link Middleware} that is composed once at route registration
* time — the resolver lambda is captured directly from the registry (no runtime map lookup):
* <pre>{@code
* // 50 req/s per IP — fixed window (default)
* app.get("/api/search", handler)
* .with(guard.limit("ip", 50, 1, TimeUnit.SECONDS));
*
* // 10 req/min per authenticated user — token bucket
* app.post("/api/export", handler)
* .with(guard.limit("auth_user", 10, 1, TimeUnit.MINUTES, LimitStrategy.TOKEN_BUCKET));
* }</pre>
*
* <p>The resolver name is looked up once here (at {@code .with(guard.limit(...))} call time,
* i.e. during app wiring, not on each request). If the name is not registered,
* {@link dev.relism.exceptions.InitializationException} is thrown immediately.
*/
public final class Guard {
private final LimiterConfig config;
private final BucketStore store;
Guard(LimiterConfig config, BucketStore store) {
this.config = config;
this.store = store;
}
/**
* Returns a {@link Middleware} that enforces the given rate limit using
* {@link LimitStrategy#FIXED_WINDOW}.
*
* @param resolverKey name registered via {@link LimiterConfig#registerResolver}
* @param requests maximum requests allowed per window
* @param window window duration in {@code unit}
* @param unit time unit for {@code window}
*/
public Middleware limit(String resolverKey, int requests, long window, TimeUnit unit) {
return limit(resolverKey, requests, window, unit, LimitStrategy.FIXED_WINDOW);
}
/**
* Returns a {@link Middleware} that enforces the given rate limit with the specified strategy.
*
* @param resolverKey name registered via {@link LimiterConfig#registerResolver}
* @param requests maximum requests allowed per window
* @param window window duration in {@code unit}
* @param unit time unit for {@code window}
* @param strategy rate-limit algorithm
*/
public Middleware limit(String resolverKey, int requests, long window, TimeUnit unit, LimitStrategy strategy) {
// Fail-fast: resolve the lambda at wiring time, not at request time.
KeyResolver resolver = config.requireResolver(resolverKey);
LimitConfig cfg = new LimitConfig(requests, unit.toMillis(window), strategy.create());
return LimiterExtension.buildMiddleware(resolver, cfg, store);
}
}
@@ -0,0 +1,20 @@
package dev.relism.ext.limiter;
import dev.relism.models.Request;
/**
* Extracts a partition key from an incoming request.
*
* <p>The resolved key identifies who the rate limit applies to — an IP address,
* an authenticated user ID, an API key, etc. Implementations are captured once
* at route registration time and called directly (no registry lookup) on every request.
*
* <pre>{@code
* conf.registerResolver("ip", req -> req.header("X-Forwarded-For"));
* conf.registerResolver("auth_user", req -> ClaimsHolder.user().sub());
* }</pre>
*/
@FunctionalInterface
public interface KeyResolver {
String resolve(Request req);
}
@@ -0,0 +1,45 @@
package dev.relism.ext.limiter;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
/**
* Applies a rate limit to a class-based {@link dev.relism.models.RequestHandler}.
*
* <p>The annotation is processed at boot time by the {@link LimiterExtension} annotation
* processor. If {@link #key()} names a resolver that was never registered,
* startup fails immediately with {@link dev.relism.exceptions.InitializationException}.
*
* <pre>{@code
* // 100 req/s per client IP — fixed window
* @Limit(requests = 100, window = 1)
* public class SearchHandler extends RequestHandler { ... }
*
* // 20 req/min per authenticated user — token bucket
* @Limit(key = "auth_user", requests = 20, window = 1, windowUnit = TimeUnit.MINUTES,
* strategy = LimitStrategy.TOKEN_BUCKET)
* public class ExpensiveHandler extends RequestHandler { ... }
* }</pre>
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Limit {
/** Name of the resolver registered via {@link LimiterConfig#registerResolver}. Default: {@code "ip"}. */
String key() default "ip";
/** Maximum number of requests allowed per {@link #window()}. */
int requests();
/** Window duration in {@link #windowUnit()} units. */
long window();
/** Unit for {@link #window()}. Default: {@link TimeUnit#SECONDS}. */
TimeUnit windowUnit() default TimeUnit.SECONDS;
/** Rate-limit algorithm. Default: {@link LimitStrategy#FIXED_WINDOW}. */
LimitStrategy strategy() default LimitStrategy.FIXED_WINDOW;
}
@@ -0,0 +1,11 @@
package dev.relism.ext.limiter;
/**
* Immutable configuration snapshot for a single rate-limit rule.
* Created once at boot time and captured directly in the middleware closure.
*
* @param limit Maximum allowed requests per window.
* @param windowMs Window duration in milliseconds.
* @param strategy Strategy instance bound to this rule (one per rule, not shared).
*/
public record LimitConfig(int limit, long windowMs, RateLimitStrategy strategy) {}
@@ -0,0 +1,45 @@
package dev.relism.ext.limiter;
import dev.relism.ext.limiter.strategy.FixedWindowStrategy;
import dev.relism.ext.limiter.strategy.TokenBucketStrategy;
/**
* Enumeration of built-in rate-limit algorithms. Each constant is a factory
* for its corresponding {@link RateLimitStrategy} implementation.
*
* <p>New algorithms can be added here without touching the rest of the extension.
* The enum value is referenced by {@link Limit#strategy()} so user code refers
* to the algorithm by name ({@code LimitStrategy.FIXED_WINDOW}) rather than
* instantiating strategy objects directly.
*
* <pre>{@code
* @Limit(key = "ip", requests = 100, window = 1, strategy = LimitStrategy.TOKEN_BUCKET)
* public class SearchHandler extends RequestHandler { ... }
* }</pre>
*/
public enum LimitStrategy {
/**
* Fixed-window counter: resets to zero at each clock-aligned window boundary.
* Simple, minimal memory, but allows up to 2× the limit in bursts that straddle
* two windows.
*/
FIXED_WINDOW {
@Override
public RateLimitStrategy create() { return new FixedWindowStrategy(); }
},
/**
* Token-bucket: tokens refill continuously. Smooth burst absorption — a client
* that was idle accumulates tokens and can fire a short burst, but sustained
* excess traffic is rejected. Preferred for API endpoints where occasional bursts
* are legitimate.
*/
TOKEN_BUCKET {
@Override
public RateLimitStrategy create() { return new TokenBucketStrategy(); }
};
/** Creates a fresh, stateless {@link RateLimitStrategy} instance for this algorithm. */
public abstract RateLimitStrategy create();
}
@@ -0,0 +1,82 @@
package dev.relism.ext.limiter;
import dev.relism.exceptions.InitializationException;
import java.net.InetSocketAddress;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Extension configuration: holds the named {@link KeyResolver} registry.
*
* <p>Resolvers are registered during the <em>config phase</em> (before {@code install}).
* After {@link LimiterExtension#install} is called, the registry is consulted once per
* route/handler at boot to capture the resolver lambda directly into the middleware closure.
* There is no map lookup on the request hot-path.
*
* <p>The built-in {@code "ip"} resolver is always present and extracts the client IP from
* {@code X-Forwarded-For} (first address) or {@code X-Real-IP}. Override it with
* {@code registerResolver("ip", ...)} if needed.
*
* <pre>{@code
* LimiterConfig conf = new LimiterConfig()
* .registerResolver("auth_user", req -> {
* // custom logic — e.g. extract sub from ClaimsHolder
* return ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous";
* });
*
* app.install(new LimiterExtension(conf));
* }</pre>
*/
public final class LimiterConfig {
private final Map<String, KeyResolver> resolvers = new LinkedHashMap<>();
public LimiterConfig() {
// Built-in mandatory "ip" resolver.
// Resolution order (standard reverse-proxy chain):
// 1. X-Forwarded-For — first address (client behind one or more proxies)
// 2. X-Real-IP — single forwarded IP (nginx proxy_set_header X-Real-IP)
// 3. Socket address — direct connection, no proxy headers (zero alloc: the
// InetSocketAddress already exists from accept(); only
// getHostAddress() allocates a String, and only when reached)
resolvers.put("ip", req -> {
String xff = req.header("X-Forwarded-For");
if (xff != null) {
int comma = xff.indexOf(',');
return comma > 0 ? xff.substring(0, comma).strip() : xff.strip();
}
String xri = req.header("X-Real-IP");
if (xri != null) return xri.strip();
InetSocketAddress addr = req.remoteAddress();
return addr != null ? addr.getAddress().getHostAddress() : "unknown";
});
}
/**
* Registers (or replaces) a named key resolver. Returns {@code this} for fluent chaining.
*
* @param name identifier referenced by {@link Limit#key()} and {@link Guard#limit}
* @param resolver lambda that extracts the partition key from a request
*/
public LimiterConfig registerResolver(String name, KeyResolver resolver) {
if (name == null || name.isBlank()) throw new IllegalArgumentException("Resolver name must not be blank");
if (resolver == null) throw new IllegalArgumentException("Resolver must not be null");
resolvers.put(name, resolver);
return this;
}
/**
* Returns the resolver for {@code name}.
*
* @throws InitializationException if no resolver with that name has been registered —
* checked at boot time so misconfigurations surface immediately.
*/
KeyResolver requireResolver(String name) {
KeyResolver r = resolvers.get(name);
if (r == null) throw new InitializationException(
"Rate-limit resolver \"" + name + "\" is not registered. " +
"Call LimiterConfig.registerResolver(\"" + name + "\", req -> ...) before install.");
return r;
}
}
@@ -0,0 +1,130 @@
package dev.relism.ext.limiter;
import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashExtension;
import dev.relism.extension.FlashRegistrar;
import dev.relism.http.HttpStatus;
import dev.relism.routing.Middleware;
import java.util.List;
/**
* Rate-limiting extension for Flash.
*
* <p>On {@link #install}:
* <ol>
* <li>Creates a single {@link BucketStore} shared by all rules in this extension instance.</li>
* <li>Provides a {@link Guard} in the {@link FlashContext} for manual use on lambda routes.</li>
* <li>Registers an {@link dev.relism.extension.AnnotationProcessor} for {@link Limit}:
* reads the annotation once per handler class at boot, resolves the key lambda
* from the registry <em>fail-fast</em>, then returns a pre-compiled middleware
* that captures the lambda and config directly — zero map lookups at request time.</li>
* </ol>
*
* <h3>Annotation-based (class handlers)</h3>
* <pre>{@code
* @Limit(requests = 100, window = 1) // 100 req/s per IP
* public class SearchHandler extends RequestHandler { ... }
*
* @Limit(key = "auth_user", requests = 20, window = 1,
* windowUnit = TimeUnit.MINUTES,
* strategy = LimitStrategy.TOKEN_BUCKET)
* public class ReportHandler extends RequestHandler { ... }
* }</pre>
*
* <h3>Lambda routes (via Guard)</h3>
* <pre>{@code
* LimiterConfig conf = new LimiterConfig()
* .registerResolver("auth_user", req -> ClaimsHolder.user().sub());
*
* app.install(new LimiterExtension(conf));
*
* Guard guard = app.ctx().require(Guard.class);
* app.get("/api/search", handler).with(guard.limit("ip", 50, 1, TimeUnit.SECONDS));
* }</pre>
*
* <h3>Installation order matters</h3>
* Install {@code LimiterExtension} <em>before</em> authentication extensions so that
* rate-limit checks short-circuit before expensive token validation on over-limit requests.
*/
public final class LimiterExtension implements FlashExtension {
private final LimiterConfig config;
/** Installs with default config (only the built-in {@code "ip"} resolver). */
public LimiterExtension() {
this(new LimiterConfig());
}
/** Installs with a custom {@link LimiterConfig} (custom resolvers, etc.). */
public LimiterExtension(LimiterConfig config) {
this.config = config;
}
@Override
public void install(FlashRegistrar app, FlashContext ctx) {
BucketStore store = new BucketStore();
Guard guard = new Guard(config, store);
ctx.provide(Guard.class, guard);
ctx.provide(LimiterConfig.class, config);
// Annotation processor: runs once per class-based handler at boot.
ctx.addAnnotationProcessor(handlerClass -> {
Limit ann = handlerClass.getAnnotation(Limit.class);
if (ann == null) return List.of();
// Fail-fast: if the key is unknown the server refuses to start.
KeyResolver resolver = config.requireResolver(ann.key());
LimitConfig cfg = new LimitConfig(
ann.requests(),
ann.windowUnit().toMillis(ann.window()),
ann.strategy().create()
);
return List.of(buildMiddleware(resolver, cfg, store));
});
}
// ── Package-private helper — shared with Guard ────────────────────────────
/**
* Builds the rate-limit {@link Middleware} from an already-resolved resolver lambda.
*
* <p>Hot-path design:
* <ul>
* <li>{@code resolver} is captured directly in the closure — no registry lookup per request.</li>
* <li>{@code resultBuf} is a per-{@link Middleware}-instance ThreadLocal {@code long[2]}.
* Allocated once per thread, reused forever — zero per-request allocation.</li>
* <li>Header values ({@code String.valueOf(...)}) are the only unavoidable allocations;
* they are tiny and bounded.</li>
* </ul>
*/
static Middleware buildMiddleware(KeyResolver resolver, LimitConfig cfg, BucketStore store) {
// One result buffer per thread, per middleware instance.
// ThreadLocal is captured in the closure at boot time — not re-created per request.
ThreadLocal<long[]> resultBuf = ThreadLocal.withInitial(() -> new long[2]);
return next -> (req, res) -> {
String key = resolver.resolve(req);
Bucket bucket = store.get(key);
long[] out = resultBuf.get();
boolean allowed = cfg.strategy().check(bucket, cfg, out);
// Always inject rate-limit headers — useful even on allowed requests.
res.header("X-RateLimit-Limit", String.valueOf(cfg.limit()));
res.header("X-RateLimit-Remaining", String.valueOf(out[0]));
res.header("X-RateLimit-Reset", String.valueOf(out[1]));
if (!allowed) {
long retryAfter = Math.max(1L, out[1] - System.currentTimeMillis() / 1000L);
res.status(HttpStatus.TOO_MANY_REQUESTS)
.header("Retry-After", String.valueOf(retryAfter));
return "Too Many Requests";
}
return next.handle(req, res);
};
}
}
@@ -0,0 +1,34 @@
package dev.relism.ext.limiter;
/**
* Contract for a rate-limit algorithm. Implementations must be:
* <ul>
* <li><b>Lock-free</b> — rely only on {@link java.util.concurrent.atomic.AtomicLong} CAS operations.</li>
* <li><b>Stateless</b> — all mutable state lives in the {@link Bucket}; the strategy itself
* holds no instance fields so the same object can be shared across threads and rules.</li>
* </ul>
*
* <p>Called on every request — must not allocate on the hot path.
*
* @see dev.relism.ext.limiter.strategy.FixedWindowStrategy
* @see dev.relism.ext.limiter.strategy.TokenBucketStrategy
*/
public interface RateLimitStrategy {
/**
* Checks whether this request is within the limit and updates the bucket atomically.
*
* <p>On return, {@code out} contains:
* <ul>
* <li>{@code out[0]} — remaining allowed requests in the current window (≥ 0).</li>
* <li>{@code out[1]} — Unix epoch seconds at which the quota resets (for {@code X-RateLimit-Reset}
* and {@code Retry-After} headers).</li>
* </ul>
*
* @param bucket per-key state carrier (pre-allocated, never null)
* @param cfg immutable rule configuration
* @param out caller-supplied two-element array; values are overwritten on every call
* @return {@code true} if the request is within the limit and should proceed
*/
boolean check(Bucket bucket, LimitConfig cfg, long[] out);
}
@@ -0,0 +1,54 @@
package dev.relism.ext.limiter.strategy;
import dev.relism.ext.limiter.Bucket;
import dev.relism.ext.limiter.LimitConfig;
import dev.relism.ext.limiter.RateLimitStrategy;
/**
* Fixed-window rate limit: allows up to {@link LimitConfig#limit()} requests per window of
* {@link LimitConfig#windowMs()} milliseconds. The window is aligned to clock time
* (e.g. 10:00:00 10:00:59 for a 60-second window), not sliding.
*
* <h3>Implementation</h3>
* The entire state fits in a single {@link java.util.concurrent.atomic.AtomicLong}
* ({@link Bucket#slot0}), packed as:
* <pre>
* high 32 bits = reduced epoch (currentTimeMs / windowMs) & 0xFFFFFFFFL
* low 32 bits = request count in the current window
* </pre>
* Each request performs a single CAS loop — no locks, no allocations.
* At a window boundary the CAS atomically resets the counter to 1.
*
* <p>The reduced epoch wraps every {@code 2^32 × windowMs} milliseconds
* (~13,000 years for a 100 ms window) — collision-free in practice.
*/
public final class FixedWindowStrategy implements RateLimitStrategy {
@Override
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
long now = System.currentTimeMillis();
long absEpoch = now / cfg.windowMs();
int epoch = (int)(absEpoch & 0xFFFFFFFFL); // reduced epoch, collision-safe
while (true) {
long packed = bucket.slot0.get();
int storedEpoch = (int)(packed >>> 32);
int count = (int)(packed & 0xFFFFFFFFL);
// Same window: increment; new window: reset to 1.
// Cap at limit+1 to guard against int overflow on extreme traffic.
int newCount = (storedEpoch == epoch)
? Math.min(count + 1, cfg.limit() + 1)
: 1;
long newPacked = ((long) epoch << 32) | (newCount & 0xFFFFFFFFL);
if (bucket.slot0.compareAndSet(packed, newPacked)) {
out[0] = Math.max(0L, cfg.limit() - newCount);
out[1] = (absEpoch + 1) * cfg.windowMs() / 1000L;
return newCount <= cfg.limit();
}
// CAS lost — contention; re-read and retry.
}
}
}
@@ -0,0 +1,69 @@
package dev.relism.ext.limiter.strategy;
import dev.relism.ext.limiter.Bucket;
import dev.relism.ext.limiter.LimitConfig;
import dev.relism.ext.limiter.RateLimitStrategy;
/**
* Token-bucket rate limit: tokens refill continuously at a rate of
* {@code limit / windowMs} tokens per millisecond, up to a maximum of {@code limit} tokens.
* Each request consumes one token. Burst traffic is absorbed until the bucket empties.
*
* <h3>Implementation</h3>
* <ul>
* <li>{@link Bucket#slot0} — current token count scaled by {@value #SCALE}
* (allows sub-token precision without floating-point). Starts at 0; treated as
* {@code maxScaled} when {@link Bucket#slot1} is 0 (first call → bucket starts full).</li>
* <li>{@link Bucket#slot1} — last-refill timestamp in ms. 0 = not yet initialised.</li>
* </ul>
*
* <p>Each request CAS-loops on {@code slot0}; {@code slot1} is updated best-effort after a
* successful CAS. The resulting inaccuracy is bounded by the nanoseconds between the CAS
* and the {@code set} — negligible and self-correcting for rate limiting purposes.
*/
public final class TokenBucketStrategy implements RateLimitStrategy {
/** Fixed-point scale factor. Stored tokens = actual tokens × SCALE. */
static final long SCALE = 1_000L;
@Override
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
long now = System.currentTimeMillis();
long maxScaled = (long) cfg.limit() * SCALE;
// Refill rate: limit tokens per windowMs → (limit * SCALE) / windowMs scaled-tokens per ms.
// Minimum 1 to ensure progress even for very large windows.
long rfPerMs = Math.max(1L, maxScaled / cfg.windowMs());
while (true) {
long lastMs = bucket.slot1.get();
long rawTokens = bucket.slot0.get();
// When slot1 == 0 the bucket has never been used: treat as one full window elapsed
// so the bucket starts completely full.
long elapsed = (lastMs == 0L) ? cfg.windowMs() : Math.max(0L, now - lastMs);
long currentTokens = Math.min(maxScaled, rawTokens + elapsed * rfPerMs);
if (currentTokens < SCALE) {
// Not enough for one token — compute when the next token arrives.
long needed = SCALE - currentTokens;
long msToNext = (needed + rfPerMs - 1) / rfPerMs; // ceiling division
out[0] = 0L;
out[1] = (now + msToNext) / 1000L;
// Best-effort: advance the refill baseline so the next call gets a fresh elapsed.
bucket.slot0.compareAndSet(rawTokens, currentTokens);
bucket.slot1.compareAndSet(lastMs, now);
return false;
}
long newTokens = currentTokens - SCALE;
if (bucket.slot0.compareAndSet(rawTokens, newTokens)) {
// Consumed successfully. Update refill baseline best-effort.
bucket.slot1.set(now);
out[0] = newTokens / SCALE;
out[1] = now / 1000L;
return true;
}
// CAS lost — another thread consumed a token concurrently; re-read and retry.
}
}
}
@@ -1,6 +1,6 @@
package dev.relism.ext.oidc;
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() {
@@ -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}.
*
+1 -1
View File
@@ -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,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();
@@ -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
@@ -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>
@@ -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);
@@ -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
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-view</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<!--
Optional engine bridges — not transitive.
Users must add whichever engine they select via ViewEngineType
to their own pom.xml. If absent at runtime, ViewExtension throws
a descriptive IllegalStateException at boot time.
-->
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
<version>3.1.2.RELEASE</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,98 @@
package dev.relism.ext.view;
import dev.relism.http.ContentType;
import dev.relism.models.Response;
/**
* Imperative view renderer — the programmatic counterpart to {@link View @View}.
*
* <p>Retrieve once at boot time in {@link dev.relism.models.RequestHandler#onInit onInit()},
* cache in a private field, and call on the hot-path with zero lookup overhead:
*
* <pre>{@code
* @Route(method = HttpMethod.GET, path = "/dashboard")
* public class DashboardHandler extends RequestHandler {
* private Renderer renderer;
* private DashboardService svc;
*
* @Override protected void onInit() {
* renderer = require(Renderer.class);
* svc = require(DashboardService.class);
* }
*
* @Override public Object handle(Request req, Response res) throws Exception {
* return renderer.view(res, "dashboard", Map.of("data", svc.stats()));
* }
* }
* }</pre>
*
* <p>For lambda handlers, capture {@code Renderer} from the context at registration
* time — it is available immediately after {@code ViewExtension} is installed:
*
* <pre>{@code
* app.install(new ViewExtension(engine));
* Renderer renderer = app.ctx().require(Renderer.class);
* app.get("/about", (req, res) -> renderer.view(res, "about"));
* }</pre>
*
* <p>The underlying {@link ViewEngine} is thread-safe after construction — no
* synchronization is needed on the hot-path.
*/
public final class Renderer {
private final ViewEngine engine;
/** Package-private — constructed exclusively by {@link ViewExtension}. */
Renderer(ViewEngine engine) {
this.engine = engine;
}
// ── Rendering ────────────────────────────────────────────────────────────
/**
* Renders {@code template} with {@code model}, sets the {@code Content-Type}
* header to {@code type}, and returns the rendered string as the handler body.
*/
public String view(Response res, String template, Object model, ContentType type) throws Exception {
res.setContentType(type);
return engine.render(template, model);
}
/**
* Renders {@code template} with {@code model} and sets
* {@code Content-Type: text/html}.
*/
public String view(Response res, String template, Object model) throws Exception {
return view(res, template, model, ContentType.TEXT_HTML);
}
/** Renders {@code template} with a {@code null} model. */
public String view(Response res, String template) throws Exception {
return view(res, template, null);
}
// ── Template signal ───────────────────────────────────────────────────────
/**
* Creates a deferred {@link Template} signal that will be intercepted by the
* {@link View @View} middleware. Use this from handlers that carry {@code @View}
* but need to dynamically override the template name or supply a different model.
*
* <p>Does <em>not</em> render immediately — rendering happens in the middleware.
*/
public Template template(String name, Object model) {
return Template.of(name, model);
}
/** Creates a {@link Template} signal with a {@code null} model. */
public Template template(String name) {
return Template.of(name);
}
// ── Escape hatch ─────────────────────────────────────────────────────────
/** Direct access to the underlying {@link ViewEngine} for advanced use cases. */
public ViewEngine engine() {
return engine;
}
}
@@ -0,0 +1,62 @@
package dev.relism.ext.view;
/**
* Explicit render signal returned from a handler to override the template name
* and/or model chosen by {@link View @View}.
*
* <p>{@code Template} is a lightweight value object — it carries the template
* name and an optional model, but performs no rendering itself. The
* {@link ViewExtension}-injected middleware detects it at the call site and
* delegates to the {@link ViewEngine}.
*
* <p>Use {@code Template} when:
* <ul>
* <li>The handler is annotated with {@code @View} but needs to redirect to a
* different template dynamically (e.g. on validation failure).</li>
* <li>A lambda handler or a handler <em>without</em> {@code @View} wants to
* trigger rendering without registering the annotation — pair with a
* {@link Renderer} captured at construction time.</li>
* </ul>
*
* <pre>{@code
* // Inside a @View-annotated handler — overrides the default template on error
* public Object handle(Request req, Response res) {
* if (!valid) return Template.of("form-error", Map.of("errors", errors));
* return service.findAll(); // falls back to @View template
* }
*
* // Lambda handler — pair with Renderer captured from ctx at boot time
* Renderer renderer = ctx.require(Renderer.class);
* app.get("/page", (req, res) -> renderer.view(res, "page", model));
* }</pre>
*/
public final class Template {
private final String name;
private final Object model;
private Template(String name, Object model) {
this.name = name;
this.model = model;
}
/** Creates a {@code Template} signal with the given name and model. */
public static Template of(String name, Object model) {
return new Template(name, model);
}
/** Creates a {@code Template} signal with a {@code null} model. */
public static Template of(String name) {
return new Template(name, null);
}
/** The template name/path to render. */
public String name() {
return name;
}
/** The model to bind; may be {@code null}. */
public Object model() {
return model;
}
}
@@ -0,0 +1,146 @@
package dev.relism.ext.view;
import org.thymeleaf.IEngineConfiguration;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.context.IExpressionContext;
import org.thymeleaf.linkbuilder.ILinkBuilder;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
/**
* {@link ViewEngine} bridge for Thymeleaf 3.x.
*
* <p>Package-private — instantiated exclusively by {@link ViewEngineType#THYMELEAF}.
*
* <h3>Default configuration</h3>
* <ul>
* <li>Prefix : {@code /templates/} (classpath-relative)</li>
* <li>Suffix : {@code .html}</li>
* <li>Mode : {@link TemplateMode#HTML}</li>
* <li>Encoding: UTF-8</li>
* <li>Cache : enabled in production, disabled in dev mode
* ({@code flash.env=dev} or {@code FLASH_ENV=dev})</li>
* </ul>
*
* <h3>Link building</h3>
* Thymeleaf's built-in {@code StandardLinkBuilder} requires an
* {@code IWebContext} (servlet context) to resolve context-relative paths
* ({@code @{/foo}}). Flash runs standalone, so this engine registers a custom
* {@link FlashLinkBuilder} that resolves {@code @{...}} expressions without a
* servlet context — path variables and query parameters are supported as usual.
*
* <h3>Model conventions</h3>
* <ul>
* <li>{@link Map} model → each entry is a named Thymeleaf variable.</li>
* <li>Any other non-null value → registered under the key {@code "it"}.</li>
* <li>{@code null} model → empty context.</li>
* </ul>
*/
final class ThymeleafEngine implements ViewEngine {
private static final String PREFIX = "/templates/";
private static final String SUFFIX = ".html";
private static final String FRAGMENT = " :: content";
private final TemplateEngine engine;
ThymeleafEngine(boolean cacheEnabled) {
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
resolver.setPrefix(PREFIX);
resolver.setSuffix(SUFFIX);
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCharacterEncoding("UTF-8");
resolver.setCacheable(cacheEnabled);
this.engine = new TemplateEngine();
this.engine.setTemplateResolver(resolver);
// Replace the default StandardLinkBuilder (which requires IWebContext)
// with our standalone-compatible link builder.
this.engine.addLinkBuilder(FlashLinkBuilder.INSTANCE);
}
@Override
public String render(String template, Object model, boolean fragment) {
Context ctx = new Context();
populateContext(ctx, model);
return engine.process(fragment ? template + FRAGMENT : template, ctx);
}
private static void populateContext(Context ctx, Object model) {
if (model instanceof Map<?, ?> map) {
map.forEach((k, v) -> ctx.setVariable(String.valueOf(k), v));
} else if (model != null) {
ctx.setVariable("it", model);
}
}
// ── Link builder ──────────────────────────────────────────────────────────
/**
* Standalone-compatible link builder for Thymeleaf's {@code @{...}} expressions.
*
* <p>Thymeleaf's built-in {@code StandardLinkBuilder} requires an
* {@code IWebContext} (i.e. a servlet container) to resolve context-relative
* paths starting with {@code /}. This builder replicates that behaviour without
* the servlet dependency:
* <ul>
* <li>Path variables — {@code @{/posts/{id}(id=${post.id})}} → {@code /posts/abc}</li>
* <li>Query params — {@code @{/search(q=${term})}} → {@code /search?q=hello}</li>
* <li>Mixed — {@code @{/posts/{id}(id=x,p=2)}} → {@code /posts/x?p=2}</li>
* </ul>
* Registered at order {@link Integer#MIN_VALUE} so it takes precedence over
* {@code StandardLinkBuilder} ({@code Integer.MAX_VALUE}).
*/
private static final class FlashLinkBuilder implements ILinkBuilder {
static final FlashLinkBuilder INSTANCE = new FlashLinkBuilder();
@Override public String getName() { return "flash"; }
@Override public Integer getOrder() { return Integer.MIN_VALUE; }
@Override
public String buildLink(IExpressionContext ctx,
String base,
Map<String, Object> params) {
if (base == null) return "";
String url = expandPathVars(base, params);
return appendQueryString(url, base, params);
}
/** Substitutes {@code {key}} placeholders in the path with their encoded values. */
private static String expandPathVars(String base, Map<String, Object> params) {
if (params == null || params.isEmpty() || !base.contains("{")) return base;
String result = base;
for (var e : params.entrySet()) {
String placeholder = '{' + e.getKey() + '}';
if (result.contains(placeholder) && e.getValue() != null) {
result = result.replace(placeholder, encode(String.valueOf(e.getValue())));
}
}
return result;
}
/** Appends parameters that were NOT consumed as path variables as {@code ?k=v&…} pairs. */
private static String appendQueryString(String url, String base, Map<String, Object> params) {
if (params == null || params.isEmpty()) return url;
StringBuilder qs = new StringBuilder();
for (var e : params.entrySet()) {
if (base.contains('{' + e.getKey() + '}') || e.getValue() == null) continue;
qs.append(qs.isEmpty() ? '?' : '&')
.append(encode(e.getKey()))
.append('=')
.append(encode(String.valueOf(e.getValue())));
}
return qs.isEmpty() ? url : url + qs;
}
private static String encode(String s) {
return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20");
}
}
}
@@ -0,0 +1,75 @@
package dev.relism.ext.view;
import dev.relism.http.ContentType;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Declarative view binding for class-based handlers.
*
* <p>When {@code ViewExtension} is installed, handlers annotated with {@code @View}
* receive an injected middleware that intercepts the handler's return value and
* passes it to the {@link ViewEngine} for rendering. The rendered string replaces
* the handler's return value as the response body, and {@link #contentType()} is
* written to the {@code Content-Type} header.
*
* <h3>Return-value semantics</h3>
* <ul>
* <li>Return a {@link Template} — overrides both the template name <em>and</em>
* the model dynamically (e.g. redirect to a different template on error).</li>
* <li>Return any other non-null value — used as the model; the template name
* comes from {@link #value()}.</li>
* <li>Return {@code null} — renders {@link #value()} with a {@code null} model.</li>
* </ul>
*
* <pre>{@code
* @Route(method = HttpMethod.GET, path = "/")
* @View("home")
* public class HomeHandler extends RequestHandler {
* private PostService posts;
* @Override protected void onInit() { posts = require(PostService.class); }
*
* @Override public Object handle(Request req, Response res) {
* return Map.of("posts", posts.findAll()); // model → home template
* }
* }
*
* // Dynamic template override via Template signal
* @View("list")
* public class ConditionalHandler extends RequestHandler {
* public Object handle(Request req, Response res) {
* if (something) return Template.of("error", Map.of("msg", "oops"));
* return data; // uses "list" template
* }
* }
* }</pre>
*
* <p>The annotation is inspected via superclass traversal, so a base handler class
* can declare the view template and all concrete subclasses inherit it.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface View {
/**
* Template name or path passed to the {@link ViewEngine}.
* The exact format is engine-specific (e.g. {@code "home"}, {@code "views/home.html"}).
*/
String value();
/**
* {@code Content-Type} written to the response.
* Defaults to {@link ContentType#TEXT_HTML}.
*/
ContentType contentType() default ContentType.TEXT_HTML;
/**
* If {@code true}, instructs the {@link ViewEngine} to render only a named
* fragment inside the template rather than the full page.
* Useful for HTMX / partial-update patterns.
*/
boolean fragment() default false;
}
@@ -0,0 +1,45 @@
package dev.relism.ext.view;
/**
* Contract for template engine integrations.
*
* <p>Implement this interface to plug any template engine (Thymeleaf, Jinjava,
* Mustache, FreeMarker, …) into the Flash view layer. A single instance is
* shared across all handlers, so implementations must be thread-safe.
*
* <pre>{@code
* // Thymeleaf example
* ViewEngine thymeleaf = (template, model, fragment) -> {
* Context ctx = new Context();
* if (model instanceof Map<?,?> m) m.forEach((k, v) -> ctx.setVariable(k.toString(), v));
* else if (model != null) ctx.setVariable("model", model);
* return engine.process(fragment ? template + " :: fragment" : template, ctx);
* };
*
* app.install(new ViewExtension(thymeleaf));
* }</pre>
*/
@FunctionalInterface
public interface ViewEngine {
/**
* Renders {@code template} with the supplied {@code model}.
*
* @param template the template name or path — engine-specific convention
* (e.g. {@code "views/home"}, {@code "home.html"})
* @param model the model object passed to the template; may be {@code null}
* @param fragment if {@code true}, only a named fragment inside the template
* should be rendered (Thymeleaf: {@code template :: fragment},
* Mustache: partial name, etc.)
* @return the rendered output string
* @throws Exception any rendering error — propagated as a 500 by the Flash runtime
*/
String render(String template, Object model, boolean fragment) throws Exception;
/**
* Convenience overload — renders the full template ({@code fragment = false}).
*/
default String render(String template, Object model) throws Exception {
return render(template, model, false);
}
}
@@ -0,0 +1,80 @@
package dev.relism.ext.view;
/**
* Managed template engine types supported out-of-the-box by {@link ViewExtension}.
*
* <p>Pass one of these constants to {@link ViewExtension#ViewExtension(ViewEngineType)}
* for zero-boilerplate setup. The extension auto-configures the selected engine with
* sensible defaults and validates that the required library is on the runtime classpath,
* throwing a descriptive {@link IllegalStateException} at boot time if it is not.
*
* <pre>{@code
* // Zero-boilerplate — Thymeleaf auto-configured with defaults
* app.install(new ViewExtension(ViewEngineType.THYMELEAF));
* }</pre>
*
* <h3>Dev mode</h3>
* Template caching is <b>disabled</b> when either:
* <ul>
* <li>the JVM property {@code flash.env} equals {@code dev} (case-insensitive), or</li>
* <li>the environment variable {@code FLASH_ENV} equals {@code dev}.</li>
* </ul>
* In all other cases caching is enabled (production default).
*
* <h3>Adding your own engine</h3>
* For unsupported engines, implement {@link ViewEngine} directly and use
* {@link ViewExtension#ViewExtension(ViewEngine)} instead.
*/
public enum ViewEngineType {
/**
* Thymeleaf 3.x — natural HTML templates with server-side rendering.
*
* <p>Required dependency (add to your {@code pom.xml}):
* <pre>{@code
* <dependency>
* <groupId>org.thymeleaf</groupId>
* <artifactId>thymeleaf</artifactId>
* <version>3.1.2.RELEASE</version>
* </dependency>
* }</pre>
*
* Default resolver: classpath, prefix {@code /templates/}, suffix {@code .html},
* mode {@code HTML}, encoding UTF-8.
*/
THYMELEAF;
// ── Factory ───────────────────────────────────────────────────────────────
/**
* Instantiates and configures the {@link ViewEngine} for this type.
* Called once at {@link ViewExtension#install} time — never on the hot-path.
*
* @param cacheEnabled whether the engine should cache compiled templates
* @throws IllegalStateException if the required library is not on the classpath
*/
ViewEngine createEngine(boolean cacheEnabled) {
return switch (this) {
case THYMELEAF -> createThymeleaf(cacheEnabled);
};
}
// ── Engine factories ──────────────────────────────────────────────────────
private static ViewEngine createThymeleaf(boolean cacheEnabled) {
try {
return new ThymeleafEngine(cacheEnabled);
} catch (NoClassDefFoundError e) {
throw new IllegalStateException("""
Thymeleaf is not on the classpath. \
Add the following dependency to your pom.xml:
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
<version>3.1.2.RELEASE</version>
</dependency>
""", e);
}
}
}
@@ -0,0 +1,166 @@
package dev.relism.ext.view;
import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashExtension;
import dev.relism.extension.FlashRegistrar;
import dev.relism.http.ContentType;
import dev.relism.routing.Middleware;
import java.util.List;
import java.util.Objects;
/**
* Installs the view layer into a Flash application.
*
* <h3>Managed mode (recommended)</h3>
* Pass a {@link ViewEngineType} — the extension auto-configures the engine,
* detects dev mode for cache settings, and validates classpath dependencies at boot:
* <pre>{@code
* app.install(new ViewExtension(ViewEngineType.THYMELEAF));
* }</pre>
*
* <h3>Manual mode (BYOE)</h3>
* Supply your own {@link ViewEngine} implementation for full control:
* <pre>{@code
* ViewEngine myEngine = (template, model, fragment) -> { ... };
* app.install(new ViewExtension(myEngine));
* }</pre>
*
* <h3>What gets installed</h3>
* <ol>
* <li>{@link ViewEngine} and {@link Renderer} are bound in the {@link FlashContext} —
* any handler can retrieve them via {@code require(Renderer.class)}.</li>
* <li>An {@link dev.relism.extension.AnnotationProcessor} is registered: class-based
* handlers carrying {@link View @View} receive an injected rendering middleware
* that intercepts the handler return value, resolves the template + model, and
* delegates to the engine. No boilerplate required in the handler itself.</li>
* </ol>
*
* <h3>Handler patterns</h3>
* <pre>{@code
* // Declarative — annotation drives template selection
* @Route(method = HttpMethod.GET, path = "/")
* @View("home")
* public class HomeHandler extends RequestHandler {
* public Object handle(Request req, Response res) {
* return Map.of("posts", service.findAll()); // model → home.html
* }
* }
*
* // Dynamic override via Template signal
* @View("list")
* public class ListHandler extends RequestHandler {
* public Object handle(Request req, Response res) {
* if (error) return Template.of("error", Map.of("msg", "oops"));
* return data; // falls back to list.html
* }
* }
*
* // Imperative — explicit render call (lambda-friendly)
* Renderer renderer = app.ctx().require(Renderer.class);
* app.get("/about", (req, res) -> renderer.view(res, "about"));
* }</pre>
*
* <h3>Dev mode / cache</h3>
* In managed mode, template caching is disabled when the JVM property
* {@code flash.env=dev} or the environment variable {@code FLASH_ENV=dev} is set.
*
* <h3>Cross-extension integration</h3>
* <pre>{@code
* ctx.optional(ViewEngine.class).ifPresent(engine -> { ... });
* }</pre>
*/
public final class ViewExtension implements FlashExtension {
private final ViewEngine engine;
// ── Constructors ──────────────────────────────────────────────────────────
/**
* Managed mode — auto-configures the engine selected by {@code type}.
*
* <p>Template caching is enabled unless {@code flash.env=dev} (JVM property)
* or {@code FLASH_ENV=dev} (environment variable) is set.
*
* @param type the engine to use; must have its library on the runtime classpath
* @throws IllegalStateException at boot time if the library is missing
*/
public ViewExtension(ViewEngineType type) {
this(type.createEngine(!isDevMode()));
}
/**
* Manual mode — use a pre-constructed {@link ViewEngine} implementation.
* Suitable for custom engines or engines that need non-default configuration.
*
* @param engine the engine implementation; must be thread-safe
*/
public ViewExtension(ViewEngine engine) {
this.engine = Objects.requireNonNull(engine, "ViewEngine must not be null");
}
// ── FlashExtension ────────────────────────────────────────────────────────
@Override
public void install(FlashRegistrar app, FlashContext ctx) {
Renderer renderer = new Renderer(engine);
ctx.provide(ViewEngine.class, engine);
ctx.provide(Renderer.class, renderer);
ctx.addAnnotationProcessor(handlerClass -> {
View view = findView(handlerClass);
if (view == null) return List.of();
String defaultTemplate = view.value();
ContentType contentType = view.contentType();
boolean fragment = view.fragment();
// Injected once per handler at boot — zero overhead on the hot-path.
// Intercepts the return value: Template signal overrides name+model;
// any other value becomes the model for the annotation's template.
Middleware renderingMiddleware = next -> (req, res) -> {
Object result = next.handle(req, res);
String tpl;
Object model;
if (result instanceof Template t) {
tpl = t.name();
model = t.model();
} else {
tpl = defaultTemplate;
model = result;
}
res.setContentType(contentType);
return engine.render(tpl, model, fragment);
};
return List.of(renderingMiddleware);
});
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Walks the superclass chain to find {@link View @View}.
* Supports inheritance: a base handler can declare the view template and
* concrete subclasses inherit it without re-annotating.
*/
private static View findView(Class<?> cls) {
while (cls != null && !cls.equals(Object.class)) {
View v = cls.getAnnotation(View.class);
if (v != null) return v;
cls = cls.getSuperclass();
}
return null;
}
/**
* Returns {@code true} when running in dev mode.
* Checks JVM property {@code flash.env} first, then env var {@code FLASH_ENV}.
*/
private static boolean isDevMode() {
String prop = System.getProperty("flash.env");
if (prop != null) return "dev".equalsIgnoreCase(prop);
String env = System.getenv("FLASH_ENV");
return "dev".equalsIgnoreCase(env);
}
}
+2
View File
@@ -18,6 +18,8 @@
<module>flash-ext-openapi</module>
<module>flash-ext-oidc</module>
<module>flash-ext-routeviewer</module>
<module>flash-ext-view</module>
<module>flash-ext-limiter</module>
</modules>
<dependencyManagement>