From fe8c6ed162613269bcc7fc6fa6ef27f2a29f8a0b Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 9 Sep 2026 12:20:33 +0000 Subject: [PATCH 1/3] fix(core): honour HttpException status in the default exception handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HttpException carries the status the caller meant, and its own javadoc says extensions map it to a structured response — but nothing did. Every one reached the catch-all and came back as 500, including the 400s that RequestHelper raises for a malformed query param and that flash-ext-jackson raises for an unparseable body. A handler doing the documented thing produced the wrong status. The default handler now renders HttpException at its own status, in both dev and prod modes, with the message JSON-escaped. Not pre-encoded like JSON_404 and JSON_500: the message is per-exception, and a path that already unwound a stack does not need the allocation shaved. Co-Authored-By: Claude Opus 5 --- .../relism/flash/routing/AbstractRouter.java | 40 +++++++++++++++++++ .../flash/routing/AbstractRouterTest.java | 23 +++++++++++ 2 files changed, 63 insertions(+) diff --git a/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java b/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java index 4473359..dc4e5ae 100644 --- a/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java +++ b/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java @@ -4,6 +4,7 @@ import dev.relism.flash.extension.FlashApp; import dev.relism.flash.models.*; import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl; import dev.relism.flash.Flash; +import dev.relism.flash.exceptions.HttpException; import dev.relism.flash.http.ContentType; import dev.relism.flash.http.HttpMethod; import dev.relism.flash.template.ErrorPages; @@ -56,17 +57,56 @@ public abstract class AbstractRouter { protected ExceptionHandler exceptionHandler = Flash.DEV ? (ex, req, res) -> { + if (ex instanceof HttpException http) return renderHttpException(http, res); res.status(500); res.type(ContentType.TEXT_HTML); return ErrorPages.renderException(req, ex); } : (ex, req, res) -> { + if (ex instanceof HttpException http) return renderHttpException(http, res); log.error("Unhandled exception in {} {}", req.method(), req.path(), ex); res.status(500); res.type(ContentType.JSON); return JSON_500; }; + /** + * {@link HttpException} carries the status the caller meant; without this it reached the + * catch-all above and every one of them came back as 500 — including the 400s + * {@code RequestHelper} and {@code flash-ext-jackson} raise for malformed input. + * + *

Deliberately not pre-encoded like {@link #JSON_404}: the message is per-exception, and + * an error path that already unwound a stack does not need the allocation shaved. + */ + private static byte[] renderHttpException(HttpException failure, Response res) { + res.status(failure.status()); + res.type(ContentType.JSON); + String message = failure.getMessage(); + StringBuilder out = new StringBuilder(48 + (message == null ? 0 : message.length())); + out.append("{\"error\":\""); + escapeJson(message == null ? "" : message, out); + out.append("\",\"status\":").append(failure.status()).append('}'); + return out.toString().getBytes(StandardCharsets.UTF_8); + } + + /** Minimal RFC 8259 string escaping — enough for an exception message. */ + private static void escapeJson(String text, StringBuilder out) { + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + switch (c) { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + default -> { + if (c < 0x20) out.append(String.format("\\u%04x", (int) c)); + else out.append(c); + } + } + } + } + public SimpleHandler getNotFoundHandler() { return notFoundHandler; } public ExceptionHandler getExceptionHandler() { return exceptionHandler; } diff --git a/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java b/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java index ede5000..9871486 100644 --- a/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java +++ b/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java @@ -1,5 +1,7 @@ package dev.relism.flash.routing; +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.http.ContentType; import dev.relism.flash.http.HttpMethod; import dev.relism.flash.models.Request; import dev.relism.flash.models.RequestHandler; @@ -7,6 +9,8 @@ import dev.relism.flash.models.Response; import dev.relism.flash.models.SimpleHandler; import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; + import static org.junit.jupiter.api.Assertions.*; class AbstractRouterTest { @@ -77,4 +81,23 @@ class AbstractRouterTest { router.onException((ex, req, res) -> "Caught"); assertEquals("Caught", router.getExceptionHandler().handle(new RuntimeException(), null, null)); } + + + /** + * HttpException carries the status the caller meant. Before this was honoured every one of + * them came back as 500, including the 400s RequestHelper and flash-ext-jackson raise. + */ + @Test + void defaultExceptionHandlerHonoursHttpExceptionStatus() throws Exception { + DummyRouter router = new DummyRouter(); + Response res = new Response(200, ContentType.TEXT_PLAIN); + + Object body = router.getExceptionHandler() + .handle(HttpException.badRequest("bad \"input\""), null, res); + + assertEquals(400, res.getStatusCode()); + String rendered = new String((byte[]) body, StandardCharsets.UTF_8); + assertTrue(rendered.contains("\\\"input\\\""), "message must be JSON-escaped: " + rendered); + assertTrue(rendered.contains("\"status\":400"), rendered); + } } From f68e66129612d19ca1cedc94c0a6978cce256302 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 9 Sep 2026 12:20:33 +0000 Subject: [PATCH 2/3] feat(ext-validation): add request validation with compiled constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standard jakarta.validation annotations, compiled once per type into a flat check table. No configuration: constraints come from the annotations already on your types, and ValidationException extends HttpException with status 422 so the default handler renders it without this extension registering anything. record CreateUser(@NotBlank @Size(max = 80) String name, @Email String email, @Min(18) int age) {} CreateUser dto = validation.body(req, CreateUser.class); Annotations only — Hibernate Validator's engine is deliberately absent. It resolves constraints reflectively per call and pulls ~2 MB plus EL, which is the per-request cost this module exists to avoid. jakarta.validation-api is ~90 KB of annotations. The passing path allocates nothing. Constraints resolve at first use into an opcode plus operands cached in a ClassValue, so there is no map lookup and no lock. Fields are read through MethodHandles adapted to an exact signature — (Object)Object for references, (Object)long for primitive integrals — so invokeExact neither boxes nor builds the argument array Field.get and Method.invoke allocate. Checks are a flat array walked by a tableswitch rather than a class hierarchy behind a virtual call. @Size reads a length the object already knows and @Email scans with indexOf, because Pattern.matcher allocates a matcher and two int arrays per call. Messages are pre-rendered at compile time. The violation list and the exception exist only once something fails. @Pattern is the marked exception: its regex compiles once but matcher() allocates per call. Constraints are read from declared fields, so records and plain classes take one code path — a constraint on a record component propagates to its backing field. Jakarta null semantics are exact: only @NotNull rejects null. flash-ext-openapi now mirrors the same annotations into the generated schema — minLength, maxLength, minItems, minimum, maximum, pattern, format: email and required — via an optional jakarta.validation dependency detected at boot. A type declares its rules once and both the validator and the published contract read them. An explicit @Schema still wins; the bridge only fills keys nobody set, and without the annotations on the classpath the bridge class is never loaded. flash-ext-jackson is optional too: validate(value) works without it, only body(req, type) needs a codec. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 +- flash-extensions/flash-ext-openapi/pom.xml | 9 + .../flash/ext/openapi/ConstraintHints.java | 84 +++++++ .../flash/ext/openapi/OpenApiBuilder.java | 11 +- .../flash-ext-validation/docs/README.md | 143 ++++++++++++ flash-extensions/flash-ext-validation/pom.xml | 51 +++++ .../relism/flash/ext/validation/Check.java | 76 ++++++ .../flash/ext/validation/Validation.java | 69 ++++++ .../ext/validation/ValidationException.java | 39 ++++ .../ext/validation/ValidationExtension.java | 37 +++ .../flash/ext/validation/Validator.java | 216 ++++++++++++++++++ .../ValidationOpenApiInteropTest.java | 68 ++++++ .../ext/validation/ValidationRoutesTest.java | 69 ++++++ .../flash/ext/validation/ValidatorTest.java | 117 ++++++++++ flash-extensions/pom.xml | 6 + pom.xml | 11 + 16 files changed, 1006 insertions(+), 2 deletions(-) create mode 100644 flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java create mode 100644 flash-extensions/flash-ext-validation/docs/README.md create mode 100644 flash-extensions/flash-ext-validation/pom.xml create mode 100644 flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java create mode 100644 flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java create mode 100644 flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java create mode 100644 flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java create mode 100644 flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java create mode 100644 flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java create mode 100644 flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java create mode 100644 flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java diff --git a/AGENTS.md b/AGENTS.md index 6ee7f45..11aa19e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ Format: `(): ` Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`, `ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`, -`ext-mcp`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`. +`ext-mcp`, `ext-validation`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`. Examples: ``` diff --git a/flash-extensions/flash-ext-openapi/pom.xml b/flash-extensions/flash-ext-openapi/pom.xml index a3ba1fe..2aae4e7 100644 --- a/flash-extensions/flash-ext-openapi/pom.xml +++ b/flash-extensions/flash-ext-openapi/pom.xml @@ -29,6 +29,15 @@ org.projectlombok lombok + + + jakarta.validation + jakarta.validation-api + true + org.junit.jupiter junit-jupiter diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java new file mode 100644 index 0000000..a92a8aa --- /dev/null +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java @@ -0,0 +1,84 @@ +package dev.relism.flash.ext.openapi; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; + +/** + * Mirrors {@code jakarta.validation} constraints into the generated schema, so a type carries its + * rules once and both the validator and the published contract read them. + * + *

Loaded reflectively by {@link OpenApiBuilder} and used only when the annotations are on the + * classpath — this class is never touched otherwise, so {@code flash-ext-openapi} keeps working + * with no validation dependency at all. Nothing to install and nothing to configure: if the + * annotations are there, the schema gains {@code minLength}, {@code maximum}, {@code format} and + * {@code required} on its own. + */ +final class ConstraintHints { + + private ConstraintHints() {} + + /** True when jakarta.validation is resolvable, so the caller may use this class. */ + static boolean available() { + try { + Class.forName("jakarta.validation.constraints.NotNull", false, ConstraintHints.class.getClassLoader()); + return true; + } catch (Throwable absent) { + return false; + } + } + + /** + * Merges {@code field}'s constraints into {@code property}, and reports whether the field is + * required. Never overwrites a key an explicit {@code @Schema} already set. + */ + static boolean apply(Field field, Map property) { + boolean isString = "string".equals(property.get("type")); + + Size size = field.getAnnotation(Size.class); + if (size != null) { + if (isString) { + if (size.min() > 0) property.putIfAbsent("minLength", size.min()); + if (size.max() != Integer.MAX_VALUE) property.putIfAbsent("maxLength", size.max()); + } else if ("array".equals(property.get("type"))) { + if (size.min() > 0) property.putIfAbsent("minItems", size.min()); + if (size.max() != Integer.MAX_VALUE) property.putIfAbsent("maxItems", size.max()); + } + } + + Min min = field.getAnnotation(Min.class); + if (min != null) property.putIfAbsent("minimum", min.value()); + + Max max = field.getAnnotation(Max.class); + if (max != null) property.putIfAbsent("maximum", max.value()); + + if (field.isAnnotationPresent(Email.class)) property.putIfAbsent("format", "email"); + + Pattern pattern = field.getAnnotation(Pattern.class); + if (pattern != null) property.putIfAbsent("pattern", pattern.regexp()); + + if (field.isAnnotationPresent(NotBlank.class) && isString) property.putIfAbsent("minLength", 1); + if (field.isAnnotationPresent(NotEmpty.class)) { + if (isString) property.putIfAbsent("minLength", 1); + else if ("array".equals(property.get("type"))) property.putIfAbsent("minItems", 1); + } + + return field.isAnnotationPresent(NotNull.class) + || field.isAnnotationPresent(NotBlank.class) + || field.isAnnotationPresent(NotEmpty.class); + } + + /** Constraint annotations this bridge understands, for documentation and tests. */ + static List supported() { + return List.of("@NotNull", "@NotBlank", "@NotEmpty", "@Size", "@Min", "@Max", "@Email", "@Pattern"); + } +} diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java index 07a52b1..a420587 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java @@ -365,6 +365,9 @@ public final class OpenApiBuilder { return null; } + /** Resolved once: jakarta.validation is an optional dependency of this module. */ + private static final boolean CONSTRAINTS_PRESENT = ConstraintHints.available(); + private static final class SchemaRegistry { private static final Set> SIMPLE = Set.of( String.class, CharSequence.class, @@ -476,8 +479,14 @@ public final class OpenApiBuilder { if (jp.access() == Access.WRITE_ONLY) property.put("writeOnly", true); } + // Constraints declared for flash-ext-validation also describe the contract, so + // mirror them here rather than making callers restate every rule as @Schema. + boolean constrainedRequired = CONSTRAINTS_PRESENT && ConstraintHints.apply(f, property); + properties.put(name, property); - if ((ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required())) required.add(name); + if (constrainedRequired + || (ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required())) + required.add(name); } if (!properties.isEmpty()) out.put("properties", properties); diff --git a/flash-extensions/flash-ext-validation/docs/README.md b/flash-extensions/flash-ext-validation/docs/README.md new file mode 100644 index 0000000..09f5076 --- /dev/null +++ b/flash-extensions/flash-ext-validation/docs/README.md @@ -0,0 +1,143 @@ +# flash-ext-validation + +Request validation for Flash. Standard `jakarta.validation` annotations, compiled once per type +into a flat check table, with zero allocation on the passing path. + +## What it provides + +| Component | Description | +|---|---| +| `Validation` | The service — `body(req, type)` parses and verifies, `validate(value)` verifies | +| `Validator` | One type's compiled constraints; reusable and thread-safe | +| `ValidationException` | 422 carrying every violation, not just the first | + +## Dependency + +```xml + + dev.relism + flash-ext-validation + ${flash.version} + +``` + +## Quick start + +```java +FlashApp.create(8080) + .install(new JacksonExtension()) + .install(new ValidationExtension()) + .scan("dev.example.api"); +``` + +```java +public record CreateUser( + @NotBlank @Size(max = 80) String name, + @Email String email, + @Min(18) int age) {} +``` + +```java +@POST("/api/users") +public final class CreateUserHandler extends RequestHandler { + + private Validation validation; + private UserService users; + + @Override protected void onInit() { + validation = require(Validation.class); + users = require(UserService.class); + } + + @Override public Object handle(Request req, Response res) throws Exception { + CreateUser dto = validation.body(req, CreateUser.class); + return res.status(201).body(users.create(dto)); + } +} +``` + +There is nothing to configure. Constraints come from the annotations already on your types, and +failures reach the client as `422` on their own — see [Error responses](#error-responses). + +## Supported constraints + +`@NotNull` · `@NotBlank` · `@NotEmpty` · `@Size` · `@Min` · `@Max` · `@Email` · `@Pattern` + +Jakarta null semantics are honoured exactly: **only `@NotNull` rejects null**. Every other +constraint passes a null value, so `@Email String email` means "if present, must look like an +email" — combine with `@NotNull` when it is mandatory. + +`@Size` applies to `CharSequence`, `Collection`, `Map` and object arrays. `@Min`/`@Max` apply to +primitive integrals and to `Number` subtypes. + +An unsupported annotation is ignored rather than rejected, so adding one is never a boot failure. + +## Records and classes + +Constraints are read from **declared fields**. A constraint on a record component propagates to +its backing field, so records and plain classes take the same path with no extra configuration: + +```java +record CreateUser(@NotBlank String name) {} // works +class CreateUser { @NotBlank private String name; } // works +``` + +## Error responses + +`ValidationException` extends Flash's `HttpException` with status 422, so the default exception +handler renders it. Nothing is registered, and your own `onException` still wins if you set one. + +```json +{"error":"name must not be blank; age must be at least 18","status":422} +``` + +Malformed JSON is a different failure and comes back as `400` from the codec, before any +constraint runs. + +## OpenAPI + +Install `flash-ext-openapi` alongside and the generated schema mirrors the same annotations — +`minLength`, `maxLength`, `minItems`, `minimum`, `maximum`, `pattern`, `format: email`, and +`required`. Declared once, enforced and published. + +Nothing registers this. `flash-ext-openapi` carries `jakarta.validation-api` as an optional +dependency and detects it at boot; without it the bridge class is never loaded. + +An explicit `@Schema` always wins — the bridge only fills keys nobody set. + +## Without Jackson + +`flash-ext-jackson` is optional. Without it `validate(value)` still works on values you construct +or parse yourself; only `body(req, type)` needs a codec and says so if one is missing. + +## Performance + +The passing path is the one that runs on every request, so it allocates nothing: + +- **Compiled once per type.** Constraints resolve to an opcode plus operands at first use, cached + in a `ClassValue` — stored beside the class by the JVM, so no map lookup, no lock, and the entry + is collected with the class rather than pinning it. +- **No reflection per request.** Fields are read through `MethodHandle`s adapted to an exact + signature: `(Object)Object` for references, `(Object)long` for primitive integrals. `invokeExact` + neither boxes nor builds the argument array that `Field.get` and `Method.invoke` allocate. +- **No megamorphic dispatch.** Checks are a flat array walked by a `tableswitch` on an opcode, not + a class hierarchy behind a virtual call. +- **No copies.** `@Size` reads a length the object already knows; `@Email` scans with `indexOf` + rather than a regex, because `Pattern.matcher` allocates a matcher and two int arrays per call. +- **Messages pre-rendered at compile time**, so even a failure formats nothing. + +The list, the violations and the exception exist only once something fails. + +`@Pattern` is the deliberate exception: its regex is compiled once, but `matcher()` allocates per +call. It is marked in the source. Prefer `@Size`/`@Email` on hot routes, or validate the shape +structurally. + +## Pre-warming + +Compilation happens on a type's first request. To pay it at boot instead: + +```java +ctx.onReady(() -> ctx.require(Validation.class).forType(CreateUser.class)); +``` + +Worth it only for a route that must not pay first-call cost. Everything else warms itself. diff --git a/flash-extensions/flash-ext-validation/pom.xml b/flash-extensions/flash-ext-validation/pom.xml new file mode 100644 index 0000000..c7fa868 --- /dev/null +++ b/flash-extensions/flash-ext-validation/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-validation + + + + dev.relism + flash + + + + jakarta.validation + jakarta.validation-api + + + + dev.relism + flash-ext-jackson + true + + + org.junit.jupiter + junit-jupiter + + + dev.relism + flash-testing + test + + + + dev.relism + flash-ext-openapi + test + + + diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java new file mode 100644 index 0000000..eda228f --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java @@ -0,0 +1,76 @@ +package dev.relism.flash.ext.validation; + +import java.lang.invoke.MethodHandle; +import java.util.regex.Pattern; + +/** + * One constraint, compiled. Flattened into an opcode plus its operands rather than a class per + * constraint type: the check loop becomes a {@code tableswitch} over a monomorphic array instead + * of a megamorphic virtual call, and a passing check touches no allocation at all. + * + *

Field access goes through a {@link MethodHandle} adapted at compile time to an exact + * signature — {@code (Object)Object} for reference fields, {@code (Object)long} for primitive + * integrals — so {@code invokeExact} neither boxes nor allocates an argument array the way + * {@code Field.get} and {@code Method.invoke} do. + */ +final class Check { + + static final int NOT_NULL = 0; + static final int NOT_BLANK = 1; + static final int NOT_EMPTY = 2; + static final int SIZE = 3; + static final int RANGE_PRIMITIVE = 4; + static final int RANGE_BOXED = 5; + static final int EMAIL = 6; + static final int PATTERN = 7; + + final int op; + final String field; + /** Pre-rendered at compile time, so even the failure path formats nothing. */ + final String message; + + /** {@code (Object)Object} — set for every op except {@link #RANGE_PRIMITIVE}. */ + final MethodHandle ref; + /** {@code (Object)long} — set only for {@link #RANGE_PRIMITIVE}. */ + final MethodHandle num; + + final int min; + final int max; + final long lo; + final long hi; + final Pattern pattern; + + private Check(int op, String field, String message, MethodHandle ref, MethodHandle num, + int min, int max, long lo, long hi, Pattern pattern) { + this.op = op; + this.field = field; + this.message = message; + this.ref = ref; + this.num = num; + this.min = min; + this.max = max; + this.lo = lo; + this.hi = hi; + this.pattern = pattern; + } + + static Check reference(int op, String field, String message, MethodHandle ref) { + return new Check(op, field, message, ref, null, 0, 0, 0, 0, null); + } + + static Check size(String field, String message, MethodHandle ref, int min, int max) { + return new Check(SIZE, field, message, ref, null, min, max, 0, 0, null); + } + + static Check rangePrimitive(String field, String message, MethodHandle num, long lo, long hi) { + return new Check(RANGE_PRIMITIVE, field, message, null, num, 0, 0, lo, hi, null); + } + + static Check rangeBoxed(String field, String message, MethodHandle ref, long lo, long hi) { + return new Check(RANGE_BOXED, field, message, ref, null, 0, 0, lo, hi, null); + } + + static Check pattern(String field, String message, MethodHandle ref, Pattern pattern) { + return new Check(PATTERN, field, message, ref, null, 0, 0, 0, 0, pattern); + } +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java new file mode 100644 index 0000000..df692c2 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java @@ -0,0 +1,69 @@ +package dev.relism.flash.ext.validation; + +import dev.relism.flash.ext.jackson.Json; +import dev.relism.flash.models.Request; + +/** + * The validation service. Resolve it with {@code require(Validation.class)}. + * + *

{@code
+ * CreateUser dto = validation.body(req, CreateUser.class);   // parse + verify
+ * }
+ * + *

Constraints are compiled the first time a type is seen and cached in a {@link ClassValue}, + * which the JVM stores beside the class itself — no map lookup, no lock, and the entry is + * collected with the class rather than pinning it. Every later request walks the compiled table. + */ +public final class Validation { + + private final ClassValue validators = new ClassValue<>() { + @Override protected Validator computeValue(Class type) { + return Validator.compile(type); + } + }; + + /** Null when flash-ext-jackson is absent; only {@link #body} needs it. */ + private Json json; + + Validation() {} + + /** Called once at boot by {@link ValidationExtension}, after the service graph resolves. */ + void bindCodec(Json json) { + this.json = json; + } + + /** + * Deserializes the request body into {@code type} and verifies its constraints. + * + * @throws dev.relism.flash.exceptions.HttpException 400 if the body is not valid JSON + * @throws ValidationException 422 if it parses but violates a constraint + */ + public T body(Request request, Class type) throws Exception { + if (json == null) + throw new IllegalStateException( + "Validation.body(...) needs a JSON codec — install JacksonExtension, " + + "or parse yourself and call validate(...)"); + T value = json.body(request, type); + validators.get(type).verify(value); + return value; + } + + /** + * Verifies an already-constructed value. + * + * @return {@code value}, so it can be used inline + * @throws ValidationException 422 on the first type's worth of failures + */ + public T validate(T value) { + validators.get(value.getClass()).verify(value); + return value; + } + + /** + * The compiled constraints of {@code type}. Useful to pre-warm a hot DTO at boot, or to + * check whether a type declares constraints at all. + */ + public Validator forType(Class type) { + return validators.get(type); + } +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java new file mode 100644 index 0000000..dac202e --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java @@ -0,0 +1,39 @@ +package dev.relism.flash.ext.validation; + +import dev.relism.flash.exceptions.HttpException; + +import java.util.List; + +/** + * Raised when a value fails its constraints. Extends {@link HttpException} with status 422, so + * Flash's default exception handler renders it without this extension registering anything. + * + *

Allocated only on failure — a passing validation constructs nothing. + */ +public final class ValidationException extends HttpException { + + private final transient List violations; + + ValidationException(List violations) { + super(422, describe(violations)); + this.violations = List.copyOf(violations); + } + + /** The individual failures, in field declaration order. */ + public List violations() { + return violations; + } + + private static String describe(List violations) { + StringBuilder out = new StringBuilder(32 * violations.size()); + for (int i = 0; i < violations.size(); i++) { + if (i > 0) out.append("; "); + Violation v = violations.get(i); + out.append(v.field()).append(' ').append(v.message()); + } + return out.toString(); + } + + /** One failed constraint. */ + public record Violation(String field, String message) {} +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java new file mode 100644 index 0000000..029594b --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java @@ -0,0 +1,37 @@ +package dev.relism.flash.ext.validation; + +import dev.relism.flash.ext.jackson.Json; +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.extension.FlashExtension; +import dev.relism.flash.extension.FlashRegistrar; + +/** + * Installs request validation. + * + *

{@code
+ * FlashApp.create(8080)
+ *     .install(new JacksonExtension())
+ *     .install(new ValidationExtension())
+ *     .scan("dev.example.api");
+ * }
+ * + *

No configuration. There is nothing to tune: constraints come from the annotations already on + * your types, failures come back as 422 through Flash's default exception handler because + * {@link ValidationException} carries its own status, and the JSON codec is picked up if + * {@code flash-ext-jackson} is installed. + * + *

Install order does not matter — Flash resolves the whole service graph before any handler + * initialises. + */ +public final class ValidationExtension implements FlashExtension { + + @Override + public void configure(FlashRegistrar app, FlashContext ctx) { + ctx.supply(Validation.class, Validation::new); + + // Resolved here rather than declared as a dependency: jackson is optional, and a declared + // dependency would make it mandatory. By the time ready callbacks run the graph is + // complete, so find() sees whatever was actually installed. + ctx.onReady(() -> ctx.require(Validation.class).bindCodec(ctx.find(Json.class).orElse(null))); + } +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java new file mode 100644 index 0000000..153a639 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java @@ -0,0 +1,216 @@ +package dev.relism.flash.ext.validation; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * The compiled constraints of one type. Built once per class and reused for every request. + * + *

{@link #verify} allocates nothing when a value passes: the loop walks an array (no iterator), + * reads fields through exact-signature {@link MethodHandle}s (no boxing, no argument array), and + * compares against operands resolved at compile time. The violation list and the exception are + * constructed only once something actually fails. + */ +public final class Validator { + + private static final Check[] NONE = new Check[0]; + + private final Check[] checks; + + private Validator(Check[] checks) { + this.checks = checks; + } + + /** True when the type declares no constraints at all — {@link #verify} is then a no-op. */ + public boolean isEmpty() { + return checks.length == 0; + } + + /** + * Verifies every constraint on {@code target}. + * + * @throws ValidationException with all failures, never just the first + */ + public void verify(Object target) { + List failures = null; + for (Check check : checks) { + if (passes(check, target)) continue; + if (failures == null) failures = new ArrayList<>(4); + failures.add(new ValidationException.Violation(check.field, check.message)); + } + if (failures != null) throw new ValidationException(failures); + } + + private static boolean passes(Check check, Object target) { + try { + if (check.op == Check.RANGE_PRIMITIVE) { + long value = (long) check.num.invokeExact(target); + return value >= check.lo && value <= check.hi; + } + Object value = (Object) check.ref.invokeExact(target); + // Jakarta semantics: only @NotNull rejects null; every other constraint passes it. + return switch (check.op) { + case Check.NOT_NULL -> value != null; + case Check.NOT_BLANK -> value instanceof String text && !text.isBlank(); + case Check.NOT_EMPTY -> value != null && sizeOf(value) > 0; + case Check.SIZE -> value == null || withinSize(check, value); + case Check.RANGE_BOXED -> value == null || withinRange(check, (Number) value); + case Check.EMAIL -> value == null || (value instanceof String text && isEmail(text)); + case Check.PATTERN -> value == null + || (value instanceof String text && check.pattern.matcher(text).matches()); + default -> true; + }; + } catch (Throwable failure) { + throw new IllegalStateException("Could not read " + check.field + " for validation", failure); + } + } + + private static boolean withinSize(Check check, Object value) { + int size = sizeOf(value); + return size >= check.min && size <= check.max; + } + + private static boolean withinRange(Check check, Number value) { + long asLong = value.longValue(); + return asLong >= check.lo && asLong <= check.hi; + } + + /** No copies: every branch reads a length the object already knows. */ + private static int sizeOf(Object value) { + if (value instanceof CharSequence text) return text.length(); + if (value instanceof Collection items) return items.size(); + if (value instanceof Map entries) return entries.size(); + if (value instanceof Object[] array) return array.length; + return 1; + } + + /** + * Structural check rather than a regex: {@code Pattern.matcher} allocates a matcher, an int + * array and a group array on every call, which is exactly the per-request cost this module + * exists to avoid. {@code indexOf} allocates nothing. + * + *

Accepts what a mail server would plausibly route and rejects the shapes people actually + * typo. Deliverability is the confirmation mail's job, not a validator's. + */ + private static boolean isEmail(String value) { + int at = value.indexOf('@'); + if (at <= 0 || at == value.length() - 1) return false; + if (value.indexOf('@', at + 1) >= 0) return false; + int dot = value.indexOf('.', at + 2); + return dot > 0 && dot < value.length() - 1 && value.indexOf(' ') < 0; + } + + // ── Compilation ────────────────────────────────────────────────────────── + + /** + * Compiles {@code type}'s constraints once. + * + *

Reads declared fields rather than record accessors: a constraint on a record component + * propagates to the backing field, so records and plain classes need one code path, not two. + */ + static Validator compile(Class type) { + MethodHandles.Lookup lookup; + try { + lookup = MethodHandles.privateLookupIn(type, MethodHandles.lookup()); + } catch (IllegalAccessException denied) { + throw new IllegalStateException( + "Cannot read " + type.getName() + " for validation — open its module or package", denied); + } + + List checks = new ArrayList<>(); + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) continue; + MethodHandle getter; + try { + getter = lookup.unreflectGetter(field); + } catch (IllegalAccessException denied) { + continue; + } + compileField(field, getter, checks); + } + return new Validator(checks.isEmpty() ? NONE : checks.toArray(new Check[0])); + } + + private static void compileField(Field field, MethodHandle getter, List checks) { + String name = field.getName(); + Class type = field.getType(); + MethodHandle ref = type.isPrimitive() ? null : asReference(getter); + + if (field.isAnnotationPresent(NotNull.class) && ref != null) + checks.add(Check.reference(Check.NOT_NULL, name, "must not be null", ref)); + + if (field.isAnnotationPresent(NotBlank.class) && ref != null) + checks.add(Check.reference(Check.NOT_BLANK, name, "must not be blank", ref)); + + if (field.isAnnotationPresent(NotEmpty.class) && ref != null) + checks.add(Check.reference(Check.NOT_EMPTY, name, "must not be empty", ref)); + + Size size = field.getAnnotation(Size.class); + if (size != null && ref != null) + checks.add(Check.size(name, sizeMessage(size), ref, size.min(), size.max())); + + Min min = field.getAnnotation(Min.class); + Max max = field.getAnnotation(Max.class); + if (min != null || max != null) { + long lo = min != null ? min.value() : Long.MIN_VALUE; + long hi = max != null ? max.value() : Long.MAX_VALUE; + String message = rangeMessage(min, max); + if (isIntegralPrimitive(type)) { + checks.add(Check.rangePrimitive(name, message, asLong(getter), lo, hi)); + } else if (Number.class.isAssignableFrom(type) && ref != null) { + checks.add(Check.rangeBoxed(name, message, ref, lo, hi)); + } + } + + if (field.isAnnotationPresent(Email.class) && ref != null) + checks.add(Check.reference(Check.EMAIL, name, "must be a well-formed email address", ref)); + + Pattern pattern = field.getAnnotation(Pattern.class); + if (pattern != null && ref != null) { + // ponytail: the one allocating check — Pattern.matcher() per call. The regex itself is + // compiled once here; swap for a structural check if a hot route ever needs it. + checks.add(Check.pattern(name, "must match " + pattern.regexp(), ref, + java.util.regex.Pattern.compile(pattern.regexp()))); + } + } + + private static boolean isIntegralPrimitive(Class type) { + return type == int.class || type == long.class || type == short.class || type == byte.class; + } + + private static MethodHandle asReference(MethodHandle getter) { + return getter.asType(MethodType.methodType(Object.class, Object.class)); + } + + private static MethodHandle asLong(MethodHandle getter) { + return getter.asType(MethodType.methodType(long.class, Object.class)); + } + + private static String sizeMessage(Size size) { + if (size.min() == 0) return "size must be at most " + size.max(); + if (size.max() == Integer.MAX_VALUE) return "size must be at least " + size.min(); + return "size must be between " + size.min() + " and " + size.max(); + } + + private static String rangeMessage(Min min, Max max) { + if (min == null) return "must be at most " + max.value(); + if (max == null) return "must be at least " + min.value(); + return "must be between " + min.value() + " and " + max.value(); + } +} diff --git a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java new file mode 100644 index 0000000..11868e6 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java @@ -0,0 +1,68 @@ +package dev.relism.flash.ext.validation; + +import dev.relism.flash.ext.jackson.JacksonExtension; +import dev.relism.flash.ext.openapi.APIResponse; +import dev.relism.flash.ext.openapi.ApiOperation; +import dev.relism.flash.ext.openapi.Content; +import dev.relism.flash.ext.openapi.OpenApiExtension; +import dev.relism.flash.http.ContentType; +import dev.relism.flash.models.Request; +import dev.relism.flash.models.RequestHandler; +import dev.relism.flash.models.Response; +import dev.relism.flash.routing.GET; +import dev.relism.flash.testing.FlashTest; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Constraints are declared once and read twice: the validator enforces them, the published schema + * describes them. Nothing registers this bridge — flash-ext-openapi picks the annotations up on + * its own when they are on the classpath. + */ +class ValidationOpenApiInteropTest { + + record Account( + @NotBlank @Size(max = 40) String name, + @Email String email, + @Min(18) @Max(120) int age) {} + + @GET("/accounts") + @ApiOperation(summary = "List accounts") + @APIResponse(responseCode = "200", content = @Content(contentType = ContentType.JSON, schema = Account.class)) + public static class ListAccounts extends RequestHandler { + @Override public Object handle(Request request, Response response) { + return new Account("alice", "a@b.com", 30); + } + } + + @RegisterExtension + static FlashTest app = FlashTest.of(configured -> { + configured.install(new JacksonExtension()); + configured.install(new ValidationExtension()); + configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0")); + configured.scan("dev.relism.flash.ext.validation"); + }); + + @Test + void constraintsAppearInTheGeneratedSchema() { + app.get("/openapi.json") + .expectStatus(200) + .expectBodyContains("\"maxLength\":40") + .expectBodyContains("\"format\":\"email\"") + .expectBodyContains("\"minimum\":18") + .expectBodyContains("\"maximum\":120"); + } + + @Test + void notBlankMarksThePropertyRequiredAndNonEmpty() { + app.get("/openapi.json") + .expectStatus(200) + .expectBodyContains("\"minLength\":1") + .expectBodyContains("\"required\":[\"name\"]"); + } +} diff --git a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java new file mode 100644 index 0000000..f32b5e2 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java @@ -0,0 +1,69 @@ +package dev.relism.flash.ext.validation; + +import dev.relism.flash.ext.jackson.JacksonExtension; +import dev.relism.flash.testing.FlashTest; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** The whole path: JSON in, constraints checked, status out — with no error handling wired up. */ +class ValidationRoutesTest { + + record CreateUser(@NotBlank @Size(max = 8) String name, @Email String email, @Min(18) int age) {} + + @RegisterExtension + static FlashTest app = FlashTest.of(configured -> { + configured.install(new JacksonExtension()); + configured.install(new ValidationExtension()); + + configured.ctx().onReady(() -> { + Validation validation = configured.ctx().require(Validation.class); + configured.post("/users", (req, res) -> + res.status(201).body("created:" + validation.body(req, CreateUser.class).name())); + }); + }); + + @Test + void validBodyReachesTheHandler() { + app.request().json("{\"name\":\"alice\",\"email\":\"a@b.com\",\"age\":30}").post("/users") + .expectStatus(201) + .expectBody("created:alice"); + } + + @Test + void constraintViolationBecomes422WithEveryFailureListed() { + app.request().json("{\"name\":\"\",\"email\":\"nope\",\"age\":5}").post("/users") + .expectStatus(422) + .expectHeader("Content-Type", "application/json") + .expectBodyContains("name must not be blank") + .expectBodyContains("email must be a well-formed email address") + .expectBodyContains("age must be at least 18"); + } + + @Test + void malformedJsonBecomes400NotAValidationFailure() { + app.request().json("not json").post("/users") + .expectStatus(400) + .expectBodyContains("Invalid request body"); + } + + /** Regression guard: HttpException used to reach the catch-all and come back as 500. */ + @Test + void statusCarriedByTheExceptionSurvivesToTheWire() { + assertEquals(422, app.request().json("{\"name\":\"x\",\"email\":\"a@b.com\",\"age\":1}") + .post("/users").status()); + } + + @Test + void errorBodyIsValidJsonEvenWhenTheMessageContainsQuotes() { + app.request().json("{\"name\":\"waaaaaaaaaay-too-long\",\"email\":\"a@b.com\",\"age\":30}").post("/users") + .expectStatus(422) + .expectBodyContains("\"status\":422") + .expectBodyContains("size must be at most 8"); + } +} diff --git a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java new file mode 100644 index 0000000..bd8e1d2 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java @@ -0,0 +1,117 @@ +package dev.relism.flash.ext.validation; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ValidatorTest { + + record CreateUser( + @NotBlank @Size(max = 8) String name, + @Email String email, + @Min(18) @Max(120) int age, + @NotNull String role) {} + + record Boxed(@Min(1) Integer count) {} + + record Sized(@NotEmpty List tags, @Size(min = 2, max = 4) String code) {} + + record Patterned(@Pattern(regexp = "[a-z]+") String slug) {} + + record Plain(String anything) {} + + private static ValidationException failureOf(Object value) { + return assertThrows(ValidationException.class, () -> Validator.compile(value.getClass()).verify(value)); + } + + @Test + void aValidValuePasses() { + assertDoesNotThrow(() -> + Validator.compile(CreateUser.class).verify(new CreateUser("alice", "a@b.com", 30, "admin"))); + } + + @Test + void reportsEveryViolationNotJustTheFirst() { + ValidationException failure = failureOf(new CreateUser(" ", "nope", 5, null)); + + assertEquals(List.of("name", "email", "age", "role"), + failure.violations().stream().map(ValidationException.Violation::field).toList()); + } + + @Test + void violationsCarryFieldAndMessage() { + ValidationException failure = failureOf(new CreateUser("alice", "a@b.com", 5, "admin")); + + assertEquals(1, failure.violations().size()); + assertEquals("age", failure.violations().get(0).field()); + assertEquals("must be between 18 and 120", failure.violations().get(0).message()); + assertEquals(422, failure.status()); + assertEquals("age must be between 18 and 120", failure.getMessage()); + } + + @Test + void sizeCountsCharactersWithoutCopying() { + assertEquals("name", failureOf(new CreateUser("far-too-long", "a@b.com", 30, "x")) + .violations().get(0).field()); + } + + @Test + void onlyNotNullRejectsNull() { + // @Email, @Size and @Min all accept null per Jakarta semantics; @NotNull is the one that does not. + ValidationException failure = failureOf(new CreateUser("alice", null, 30, null)); + + assertEquals(List.of("role"), + failure.violations().stream().map(ValidationException.Violation::field).toList()); + } + + @Test + void boxedNumbersUseTheReferencePathAndTolerateNull() { + assertDoesNotThrow(() -> Validator.compile(Boxed.class).verify(new Boxed(null))); + assertEquals("count", failureOf(new Boxed(0)).violations().get(0).field()); + } + + @Test + void sizeAppliesToCollectionsAndStrings() { + assertDoesNotThrow(() -> Validator.compile(Sized.class).verify(new Sized(List.of("a"), "abc"))); + + ValidationException failure = failureOf(new Sized(List.of(), "x")); + assertEquals(List.of("tags", "code"), + failure.violations().stream().map(ValidationException.Violation::field).toList()); + } + + @Test + void patternIsAnchoredLikeJakarta() { + assertDoesNotThrow(() -> Validator.compile(Patterned.class).verify(new Patterned("abc"))); + assertEquals("slug", failureOf(new Patterned("Abc1")).violations().get(0).field()); + } + + @Test + void emailAcceptsPlausibleAddressesAndRejectsTypos() { + assertDoesNotThrow(() -> + Validator.compile(CreateUser.class).verify(new CreateUser("a", "first.last@sub.example.co", 20, "x"))); + + for (String bad : List.of("no-at", "@leading.com", "trailing@", "two@@at.com", "no dots@x", "a@b")) { + assertThrows(ValidationException.class, + () -> Validator.compile(CreateUser.class).verify(new CreateUser("a", bad, 20, "x")), + bad); + } + } + + @Test + void aTypeWithNoConstraintsCompilesToANoOp() { + Validator validator = Validator.compile(Plain.class); + + assertTrue(validator.isEmpty()); + assertDoesNotThrow(() -> validator.verify(new Plain(null))); + } +} diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index 70452eb..3749b9b 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -24,6 +24,7 @@ flash-ext-limiter flash-ext-web-bundler flash-ext-mcp + flash-ext-validation flash-ext-data-core flash-ext-data-jdbc flash-ext-data-hibernate @@ -31,6 +32,11 @@ + + dev.relism + flash-ext-validation + ${project.version} + dev.relism flash-testing diff --git a/pom.xml b/pom.xml index aaa7c7e..c8bd745 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,7 @@ 2.18.0 1.37 5.11.0 + 3.1.1 3.6.0 @@ -67,6 +68,16 @@ flash-testing ${project.version} + + dev.relism + flash-ext-validation + ${project.version} + + + jakarta.validation + jakarta.validation-api + ${jakarta.validation.version} + dev.relism flash-ext-jackson From 24bb10175d10c43f5a3765e569e11750ded142e8 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 9 Sep 2026 12:30:03 +0000 Subject: [PATCH 3/3] feat(ext-cache-core): add the caching contract and a Caffeine backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the way flash-ext-data and flash-ext-view are: cache-core defines Cache, CacheManager, CacheSpec and CacheStats and talks to nothing; cache-caffeine implements them in process. users = require(CacheManager.class).build("users", spec -> spec .maxSize(10_000).ttl(Duration.ofMinutes(10))); return users.get(id, repo::findById); get(key, loader) is the only shape most code needs and the only one that is hard to get right: the loader runs once per key across concurrent callers rather than each racing its own. A null result stores nothing, because caching absence is a decision rather than a default. build(name, spec) is idempotent per name, so two handlers wanting one cache get one cache without coordinating who creates it. Disagreeing about the spec throws rather than resolving to whichever handler initialised first, which is a bug that only surfaces under load. recordStats() is opt-in — counting is two atomic increments per lookup, and a cache nobody measures should not pay for numbers nobody reads. Unmeasured caches return CacheStats.DISABLED rather than zeroes that look like a cold cache. Caffeine rather than a hand-rolled LRU: for genuinely low traffic ConcurrentHashMap::computeIfAbsent is one line and needs no module at all, and this exists for when that stops being true. W-TinyLFU admission, striped counters and amortised eviction are not a weekend's work, and getting them wrong yields a cache slower than no cache. The adapter is deliberately thin — every method delegates, adding no wrapper, copy or locking of its own. Caches are dropped through FlashContext.onClose, so values do not outlive the app holding them. Invisible with one app per process; immediate under test. flash-ext-cache-redis is designed but not built, and has docs only — no module, no pom, no source. An empty module that builds an empty jar is dead weight in the reactor. The docs record what changes once the cache can fail: get() must decide whether to fall through to the loader, values need a codec, invalidateAll needs a key prefix that becomes wire contract, and eviction stats stop meaning anything. Those are decisions that want a real second replica to check them against. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 +- README.md | 7 + .../flash-ext-cache-caffeine/docs/README.md | 85 ++++++++++ .../flash-ext-cache-caffeine/pom.xml | 39 +++++ .../ext/cache/caffeine/CaffeineCache.java | 41 +++++ .../caffeine/CaffeineCacheExtension.java | 33 ++++ .../cache/caffeine/CaffeineCacheManager.java | 66 ++++++++ .../ext/cache/caffeine/CaffeineCacheTest.java | 160 ++++++++++++++++++ .../flash-ext-cache-core/docs/README.md | 64 +++++++ flash-extensions/flash-ext-cache-core/pom.xml | 25 +++ .../dev/relism/flash/ext/cache/Cache.java | 50 ++++++ .../relism/flash/ext/cache/CacheManager.java | 31 ++++ .../dev/relism/flash/ext/cache/CacheSpec.java | 64 +++++++ .../relism/flash/ext/cache/CacheStats.java | 20 +++ .../flash-ext-cache-redis/docs/README.md | 39 +++++ flash-extensions/pom.xml | 17 ++ pom.xml | 11 ++ 17 files changed, 753 insertions(+), 1 deletion(-) create mode 100644 flash-extensions/flash-ext-cache-caffeine/docs/README.md create mode 100644 flash-extensions/flash-ext-cache-caffeine/pom.xml create mode 100644 flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCache.java create mode 100644 flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheExtension.java create mode 100644 flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheManager.java create mode 100644 flash-extensions/flash-ext-cache-caffeine/src/test/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheTest.java create mode 100644 flash-extensions/flash-ext-cache-core/docs/README.md create mode 100644 flash-extensions/flash-ext-cache-core/pom.xml create mode 100644 flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/Cache.java create mode 100644 flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheManager.java create mode 100644 flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheSpec.java create mode 100644 flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheStats.java create mode 100644 flash-extensions/flash-ext-cache-redis/docs/README.md diff --git a/AGENTS.md b/AGENTS.md index 6ee7f45..3a6fa6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ Format: `(): ` Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`, `ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`, -`ext-mcp`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`. +`ext-mcp`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `ext-cache-core`, `ext-cache-caffeine`, `release`, `deps`, `ci`. Examples: ``` diff --git a/README.md b/README.md index 84cf184..10ac1e4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res | `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives | | `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension | | `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension | +| `flash-extensions/flash-ext-validation` | Request validation — jakarta constraints, compiled once per type | +| `flash-extensions/flash-ext-scheduler` | Interval and cron background jobs on virtual threads | +| `flash-extensions/flash-ext-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` | +| `flash-extensions/flash-ext-cache-caffeine` | In-process cache backed by Caffeine | ## Requirements @@ -146,6 +150,9 @@ See extension-specific READMEs for full details: - [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md) - [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md) - [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md) +- [`flash-ext-validation`](flash-extensions/flash-ext-validation/docs/README.md) +- [`flash-ext-scheduler`](flash-extensions/flash-ext-scheduler/docs/README.md) +- [`flash-ext-cache-caffeine`](flash-extensions/flash-ext-cache-caffeine/docs/README.md) - [`flash-testing`](flash-testing/docs/README.md) ## Error handlers diff --git a/flash-extensions/flash-ext-cache-caffeine/docs/README.md b/flash-extensions/flash-ext-cache-caffeine/docs/README.md new file mode 100644 index 0000000..757ed16 --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/docs/README.md @@ -0,0 +1,85 @@ +# flash-ext-cache-caffeine + +In-process caching backed by [Caffeine](https://github.com/ben-manes/caffeine). Implements +[`flash-ext-cache-core`](../../flash-ext-cache-core/docs/README.md). + +## Dependency + +```xml + + dev.relism + flash-ext-cache-caffeine + ${flash.version} + +``` + +## Quick start + +```java +FlashApp.create(8080) + .install(new CaffeineCacheExtension()) + .scan("dev.example.api"); +``` + +```java +@GET("/api/users/{id}") +public final class GetUser extends RequestHandler { + + private Cache users; + private UserRepository repo; + + @Override protected void onInit() { + repo = require(UserRepository.class); + users = require(CacheManager.class).build("users", spec -> spec + .maxSize(10_000) + .ttl(Duration.ofMinutes(10))); + } + + @Override public Object handle(Request req, Response res) { + return users.get(req.param("id"), repo::findById); + } +} +``` + +The extension takes no configuration. Each cache declares its own size and TTL where it is built. + +## Why Caffeine and not a `LinkedHashMap` + +An LRU on top of `LinkedHashMap` is about sixty lines, and for a cache that is genuinely +low-traffic it is the right answer — `ConcurrentHashMap::computeIfAbsent` is one line and has no +hit rate to get wrong. + +This module exists for the case where that stops being true. Caffeine's W-TinyLFU admission, +striped frequency counters and amortised eviction are not a weekend's work to reproduce, and the +failure mode of getting them wrong is a cache that is *slower* than no cache — lock contention on +every lookup, or an eviction policy that throws away exactly the entries you were about to want. + +## Lifecycle + +Caches are released through `FlashContext.onClose`, so `app.stop()` drops every entry. That is +invisible in production with one app per process and matters immediately under test, where many +apps start and stop in one JVM. + +## Statistics + +```java +CacheStats stats = users.stats(); +stats.hitRate(); // 0.0 until something is looked up +``` + +Requires `recordStats()` on the spec. Without it you get `CacheStats.DISABLED`, which is honest +about being unmeasured rather than reporting zeroes that look like a cold cache. + +`manager.names()` lists every cache built so far, for an ops endpoint. + +## What this is not + +**HTTP caching.** If what you want is for the *client* to stop asking — `Cache-Control`, `ETag`, +`304 Not Modified` — that is a middleware, not an object cache, and it saves the whole request +rather than the lookup inside it. Reach for that first: it is cheaper, and the two solve different +problems. + +**A shared cache.** Every replica has its own. Two instances will hold different values for the +same key, and an invalidation on one does not reach the other. When that becomes a problem the +answer is a networked backend — see the note on `flash-ext-cache-redis` — and the semantics change +with it: a cache that can fail is no longer transparent. diff --git a/flash-extensions/flash-ext-cache-caffeine/pom.xml b/flash-extensions/flash-ext-cache-caffeine/pom.xml new file mode 100644 index 0000000..0141f3d --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-cache-caffeine + + + + dev.relism + flash-ext-cache-core + + + + com.github.ben-manes.caffeine + caffeine + + + org.junit.jupiter + junit-jupiter + + + dev.relism + flash-testing + test + + + diff --git a/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCache.java b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCache.java new file mode 100644 index 0000000..b884194 --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCache.java @@ -0,0 +1,41 @@ +package dev.relism.flash.ext.cache.caffeine; + +import dev.relism.flash.ext.cache.Cache; +import dev.relism.flash.ext.cache.CacheStats; + +import java.util.function.Function; + +/** + * {@link Cache} over a Caffeine cache. A thin adapter by design: every method delegates directly, + * adding no wrapper object, no copy and no synchronisation of its own. + */ +final class CaffeineCache implements Cache { + + private final com.github.benmanes.caffeine.cache.Cache delegate; + private final boolean statsRecorded; + + CaffeineCache(com.github.benmanes.caffeine.cache.Cache delegate, boolean statsRecorded) { + this.delegate = delegate; + this.statsRecorded = statsRecorded; + } + + @Override + public V get(K key, Function loader) { + // Caffeine's own get(key, mappingFunction) already guarantees the loader runs once per key + // across concurrent callers; wrapping it in anything of ours would only add a race. + return delegate.get(key, loader); + } + + @Override public V getIfPresent(K key) { return delegate.getIfPresent(key); } + @Override public void put(K key, V value) { delegate.put(key, value); } + @Override public void invalidate(K key) { delegate.invalidate(key); } + @Override public void invalidateAll() { delegate.invalidateAll(); } + @Override public long estimatedSize() { return delegate.estimatedSize(); } + + @Override + public CacheStats stats() { + if (!statsRecorded) return CacheStats.DISABLED; + com.github.benmanes.caffeine.cache.stats.CacheStats snapshot = delegate.stats(); + return new CacheStats(snapshot.hitCount(), snapshot.missCount(), snapshot.evictionCount()); + } +} diff --git a/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheExtension.java b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheExtension.java new file mode 100644 index 0000000..f7b39f3 --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheExtension.java @@ -0,0 +1,33 @@ +package dev.relism.flash.ext.cache.caffeine; + +import dev.relism.flash.ext.cache.CacheManager; +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.extension.FlashExtension; +import dev.relism.flash.extension.FlashRegistrar; + +/** + * Installs an in-process {@link CacheManager} backed by Caffeine. + * + *

{@code
+ * FlashApp.create(8080)
+ *     .install(new CaffeineCacheExtension())
+ *     .scan("dev.example.api");
+ * }
+ * + *

No configuration. Each cache declares its own size and TTL where it is built, because those + * are properties of what is being cached, not of the process caching it. + * + *

Caches are dropped through {@link FlashContext#onClose}, so a stopped app does not keep its + * values alive — which matters when many apps start and stop in one JVM, as they do under test. + */ +public final class CaffeineCacheExtension implements FlashExtension { + + @Override + public void configure(FlashRegistrar app, FlashContext ctx) { + ctx.supply(CacheManager.class, services -> { + CaffeineCacheManager manager = new CaffeineCacheManager(); + services.onClose(manager::clear); + return manager; + }); + } +} diff --git a/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheManager.java b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheManager.java new file mode 100644 index 0000000..7f6332c --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/src/main/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheManager.java @@ -0,0 +1,66 @@ +package dev.relism.flash.ext.cache.caffeine; + +import com.github.benmanes.caffeine.cache.Caffeine; +import dev.relism.flash.ext.cache.Cache; +import dev.relism.flash.ext.cache.CacheManager; +import dev.relism.flash.ext.cache.CacheSpec; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** In-process {@link CacheManager} backed by Caffeine. */ +final class CaffeineCacheManager implements CacheManager { + + private final Map caches = new ConcurrentHashMap<>(); + + @Override + @SuppressWarnings("unchecked") + public Cache build(String name, java.util.function.Consumer configure) { + CacheSpec spec = CacheSpec.of(); + configure.accept(spec); + + Entry entry = caches.computeIfAbsent(name, key -> new Entry(describe(spec), create(spec))); + // Two handlers sharing a cache is the point; two handlers disagreeing about its size or + // TTL is a bug that would otherwise resolve to whichever one ran first. + String requested = describe(spec); + if (!entry.signature.equals(requested)) + throw new IllegalStateException("Cache '" + name + "' already exists as " + entry.signature + + " but was requested as " + requested); + return (Cache) entry.cache; + } + + @Override + @SuppressWarnings("unchecked") + public Cache cache(String name) { + Entry entry = caches.get(name); + return entry == null ? null : (Cache) entry.cache; + } + + @Override + public Set names() { + return Set.copyOf(caches.keySet()); + } + + /** Releases every entry so a stopped app does not keep its values alive. */ + void clear() { + caches.values().forEach(entry -> entry.cache.invalidateAll()); + caches.clear(); + } + + private static CaffeineCache create(CacheSpec spec) { + Caffeine builder = Caffeine.newBuilder(); + if (spec.bounded()) builder.maximumSize(spec.maxSize()); + if (spec.ttl() != null) builder.expireAfterWrite(spec.ttl()); + if (spec.ttlAfterAccess() != null) builder.expireAfterAccess(spec.ttlAfterAccess()); + if (spec.statsRecorded()) builder.recordStats(); + return new CaffeineCache<>(builder.build(), spec.statsRecorded()); + } + + private static String describe(CacheSpec spec) { + return "maxSize=" + spec.maxSize() + " ttl=" + spec.ttl() + + " ttlAfterAccess=" + spec.ttlAfterAccess() + " stats=" + spec.statsRecorded(); + } + + private record Entry(String signature, CaffeineCache cache) {} +} diff --git a/flash-extensions/flash-ext-cache-caffeine/src/test/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheTest.java b/flash-extensions/flash-ext-cache-caffeine/src/test/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheTest.java new file mode 100644 index 0000000..8c85a42 --- /dev/null +++ b/flash-extensions/flash-ext-cache-caffeine/src/test/java/dev/relism/flash/ext/cache/caffeine/CaffeineCacheTest.java @@ -0,0 +1,160 @@ +package dev.relism.flash.ext.cache.caffeine; + +import dev.relism.flash.ext.cache.Cache; +import dev.relism.flash.ext.cache.CacheManager; +import dev.relism.flash.ext.cache.CacheStats; +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.testing.FlashTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class CaffeineCacheTest { + + private static final AtomicInteger loads = new AtomicInteger(); + + @RegisterExtension + static FlashTest app = FlashTest.of(configured -> { + configured.install(new CaffeineCacheExtension()); + configured.ctx().onReady(() -> { + Cache users = configured.ctx().require(CacheManager.class) + .build("users", spec -> spec.maxSize(100).ttl(Duration.ofMinutes(5)).recordStats()); + configured.get("/users/{id}", (req, res) -> + users.get(req.param("id"), id -> "loaded:" + id + ":" + loads.incrementAndGet())); + }); + }); + + private static CacheManager manager() { + return app.app().ctx().require(CacheManager.class); + } + + @Test + void aRepeatedRequestIsServedFromCache() { + String first = app.get("/users/alice").expectStatus(200).body(); + String second = app.get("/users/alice").expectStatus(200).body(); + + assertEquals(first, second); + assertTrue(first.startsWith("loaded:alice:")); + } + + @Test + void distinctKeysLoadSeparately() { + assertNotEquals(app.get("/users/bob").body(), app.get("/users/carol").body()); + } + + @Test + void statsCountHitsAndMisses() { + Cache cache = manager().build("stats-probe", spec -> spec.maxSize(10).recordStats()); + cache.get("k", key -> "v"); + cache.get("k", key -> "v"); + + CacheStats stats = cache.stats(); + assertEquals(1, stats.misses()); + assertEquals(1, stats.hits()); + assertEquals(0.5, stats.hitRate()); + } + + @Test + void statsAreDisabledUnlessAskedFor() { + Cache cache = manager().build("no-stats", spec -> spec.maxSize(10)); + cache.get("k", key -> "v"); + + assertEquals(CacheStats.DISABLED, cache.stats()); + } + + @Test + void theLoaderRunsOncePerKeyUnderConcurrency() throws Exception { + Cache cache = manager().build("single-flight", spec -> spec.maxSize(10)); + AtomicInteger invocations = new AtomicInteger(); + int threads = 16; + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + + for (int i = 0; i < threads; i++) { + Thread.ofVirtual().start(() -> { + try { + start.await(); + cache.get("hot", key -> { + invocations.incrementAndGet(); + return "value"; + }); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }); + } + start.countDown(); + assertTrue(done.await(5, java.util.concurrent.TimeUnit.SECONDS)); + + assertEquals(1, invocations.get(), "concurrent callers must share one load, not race"); + } + + @Test + void aNullLoaderResultStoresNothing() { + Cache cache = manager().build("nulls", spec -> spec.maxSize(10)); + + assertNull(cache.get("missing", key -> null)); + assertNull(cache.getIfPresent("missing")); + } + + @Test + void invalidateDropsOneKeyAndInvalidateAllDropsEverything() { + Cache cache = manager().build("invalidation", spec -> spec.maxSize(10)); + cache.put("a", "1"); + cache.put("b", "2"); + + cache.invalidate("a"); + assertNull(cache.getIfPresent("a")); + assertEquals("2", cache.getIfPresent("b")); + + cache.invalidateAll(); + assertNull(cache.getIfPresent("b")); + } + + @Test + void buildIsIdempotentPerName() { + Cache first = manager().build("shared", spec -> spec.maxSize(10)); + Cache second = manager().build("shared", spec -> spec.maxSize(10)); + + assertSame(first, second, "two handlers asking for one cache must get one cache"); + assertSame(first, manager().cache("shared")); + } + + @Test + void disagreeingOnASharedCacheIsARejectedMistakeNotASilentWinner() { + manager().build("contested", spec -> spec.maxSize(10)); + + IllegalStateException conflict = assertThrows(IllegalStateException.class, + () -> manager().build("contested", spec -> spec.maxSize(999))); + assertTrue(conflict.getMessage().contains("contested"), conflict.getMessage()); + } + + @Test + void unknownNameReturnsNullRatherThanBuildingOne() { + assertNull(manager().cache("never-built")); + } + + /** Why CaffeineCacheExtension registers onClose: values must not outlive the app holding them. */ + @Test + void stoppingTheAppReleasesEveryCache() { + FlashApp standalone = FlashApp.create(FlashConfiguration.builder() + .port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build()) + .install(new CaffeineCacheExtension()); + standalone.start(); + CacheManager manager = standalone.ctx().require(CacheManager.class); + manager.build("scoped", spec -> spec.maxSize(10)).put("k", "v"); + assertEquals(1, manager.names().size()); + + standalone.stop().join(); + + assertTrue(manager.names().isEmpty(), "caches must be released when the app stops"); + } +} diff --git a/flash-extensions/flash-ext-cache-core/docs/README.md b/flash-extensions/flash-ext-cache-core/docs/README.md new file mode 100644 index 0000000..ffdc3d2 --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/docs/README.md @@ -0,0 +1,64 @@ +# flash-ext-cache-core + +The caching contract, shared across backends. Like `flash-ext-data-core`, this module talks to +nothing: it defines the abstractions and a backend implements them. + +## Components + +- `Cache` — a named cache. `get(key, loader)` is the method that matters. +- `CacheManager` — creates and hands back named caches. +- `CacheSpec` — size and expiry for one cache. +- `CacheStats` — hit/miss/eviction counters. + +Install a backend, not this module: [`flash-ext-cache-caffeine`](../../flash-ext-cache-caffeine/docs/README.md) +for in-process caching. + +## The one shape that matters + +```java +User user = users.get(id, repo::findById); +``` + +Compute-if-absent is the only cache operation most code needs, and the only one that is hard to +get right — the loader runs **once per key** across concurrent callers, and the rest wait rather +than each computing their own. `getIfPresent`, `put`, `invalidate` and `invalidateAll` exist for +what it cannot express. + +A loader returning `null` stores nothing and returns `null`. Caching absence is a decision, not a +default; wrap it in an `Optional` or a sentinel if you want it. + +## Naming and sharing + +`CacheManager.build(name, spec)` is idempotent per name: two handlers asking for `"users"` get one +cache, not two, so nobody has to coordinate who creates it first. + +If they disagree about the spec, that throws. The alternative is a cache whose size depends on +which handler happened to initialise first, which is the kind of bug that only shows up under +load. + +## Specs + +```java +CacheSpec.of() + .maxSize(10_000) + .ttl(Duration.ofMinutes(10)) + .recordStats(); +``` + +Every field is optional, but a spec that sets neither `maxSize` nor `ttl` is an unbounded cache +that never expires — a memory leak wearing a hat. Set at least one. + +`recordStats()` is off by default: counting costs a pair of atomic increments on every lookup, and +a cache nobody is measuring should not pay for numbers nobody reads. Without it, `stats()` returns +`CacheStats.DISABLED` rather than silently zero. + +## Where the spec lives + +On the cache, at the point it is built — not in application config. Size and TTL are properties of +*what is being cached*, not of the process doing the caching, and a TTL in a config file is a TTL +nobody can relate back to the data it governs. + +## Writing a backend + +Implement `CacheManager` and `Cache`, provide the manager from a `FlashExtension`, and register +cleanup with `FlashContext.onClose` so a stopped app does not keep its values alive. diff --git a/flash-extensions/flash-ext-cache-core/pom.xml b/flash-extensions/flash-ext-cache-core/pom.xml new file mode 100644 index 0000000..0a825be --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-cache-core + + + + dev.relism + flash + + + org.junit.jupiter + junit-jupiter + + + diff --git a/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/Cache.java b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/Cache.java new file mode 100644 index 0000000..eb27faf --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/Cache.java @@ -0,0 +1,50 @@ +package dev.relism.flash.ext.cache; + +import java.util.function.Function; + +/** + * A named cache. Obtained from a {@link CacheManager}, safe to hold in a handler field and share + * across threads. + * + *

{@code
+ * User user = users.get(id, repo::findById);
+ * }
+ * + *

{@link #get} is the only method most code needs. The rest exist for the cases it cannot + * express: reading without populating, writing a value computed elsewhere, and invalidating. + * + * @param key type — must have a stable {@code hashCode}/{@code equals} + * @param value type + */ +public interface Cache { + + /** + * Returns the cached value, computing and storing it with {@code loader} if absent. + * + *

The loader runs at most once per key across concurrent callers; the others wait for it + * rather than each computing their own. A loader returning {@code null} stores nothing and + * {@code null} is returned. + */ + V get(K key, Function loader); + + /** The cached value, or {@code null} if absent. Never invokes a loader. */ + V getIfPresent(K key); + + /** Stores {@code value}, replacing any existing entry. */ + void put(K key, V value); + + /** Drops {@code key}. Does nothing if it was absent. */ + void invalidate(K key); + + /** Drops every entry. */ + void invalidateAll(); + + /** Approximate entry count. Approximate because eviction is asynchronous in most backends. */ + long estimatedSize(); + + /** + * Hit/miss counters since this cache was built, or {@link CacheStats#DISABLED} when the + * backend was not asked to record them. + */ + CacheStats stats(); +} diff --git a/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheManager.java b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheManager.java new file mode 100644 index 0000000..26adb07 --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheManager.java @@ -0,0 +1,31 @@ +package dev.relism.flash.ext.cache; + +import java.util.function.Consumer; + +/** + * Creates and hands back named caches. Resolve it with {@code require(CacheManager.class)}; a + * backend extension such as {@code flash-ext-cache-caffeine} provides the implementation. + * + *

{@code
+ * @Override protected void onInit() {
+ *     users = require(CacheManager.class).build("users", spec -> spec
+ *             .maxSize(10_000)
+ *             .ttl(Duration.ofMinutes(10)));
+ * }
+ * }
+ * + *

{@link #build} is idempotent per name: calling it twice returns the same cache rather than + * two, so several handlers can share one without coordinating who creates it. The spec of the + * first call wins; a later call with a different spec is a configuration mistake and throws. + */ +public interface CacheManager { + + /** Creates the named cache, or returns the existing one. */ + Cache build(String name, Consumer spec); + + /** The named cache, or {@code null} if {@link #build} has not been called for it. */ + Cache cache(String name); + + /** Every cache name built so far, for an ops endpoint. */ + java.util.Set names(); +} diff --git a/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheSpec.java b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheSpec.java new file mode 100644 index 0000000..3897ce6 --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheSpec.java @@ -0,0 +1,64 @@ +package dev.relism.flash.ext.cache; + +import java.time.Duration; + +/** + * How one cache should behave. Every field is optional — a spec that sets nothing gives an + * unbounded cache that never expires, which is a memory leak wearing a hat, so set at least one + * of {@link #maxSize} or {@link #ttl}. + * + *

{@code
+ * CacheSpec.of().maxSize(10_000).ttl(Duration.ofMinutes(10))
+ * }
+ * + *

Mutable builder rather than a record with {@code withX} copies: it is constructed once at + * boot inside a lambda and never shared. + */ +public final class CacheSpec { + + private long maxSize = -1; + private Duration ttl; + private Duration ttlAfterAccess; + private boolean recordStats; + + private CacheSpec() {} + + public static CacheSpec of() { + return new CacheSpec(); + } + + /** Maximum entries before the backend starts evicting. Negative means unbounded. */ + public CacheSpec maxSize(long maxSize) { + this.maxSize = maxSize; + return this; + } + + /** Entries expire this long after they were written. */ + public CacheSpec ttl(Duration ttl) { + this.ttl = ttl; + return this; + } + + /** Entries expire this long after they were last read or written. */ + public CacheSpec ttlAfterAccess(Duration ttlAfterAccess) { + this.ttlAfterAccess = ttlAfterAccess; + return this; + } + + /** + * Records hit/miss counters for {@link Cache#stats()}. + * + *

Off by default: counting costs a pair of atomic increments on every lookup, and a cache + * nobody is measuring should not pay for numbers nobody reads. + */ + public CacheSpec recordStats() { + this.recordStats = true; + return this; + } + + public long maxSize() { return maxSize; } + public Duration ttl() { return ttl; } + public Duration ttlAfterAccess() { return ttlAfterAccess; } + public boolean statsRecorded() { return recordStats; } + public boolean bounded() { return maxSize >= 0; } +} diff --git a/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheStats.java b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheStats.java new file mode 100644 index 0000000..a48276c --- /dev/null +++ b/flash-extensions/flash-ext-cache-core/src/main/java/dev/relism/flash/ext/cache/CacheStats.java @@ -0,0 +1,20 @@ +package dev.relism.flash.ext.cache; + +/** + * Hit/miss counters for one cache. + * + * @param hits lookups that found a value + * @param misses lookups that had to load + * @param evictions entries dropped to respect {@link CacheSpec#maxSize()} + */ +public record CacheStats(long hits, long misses, long evictions) { + + /** Returned when {@link CacheSpec#recordStats()} was not set — all zero, and says so. */ + public static final CacheStats DISABLED = new CacheStats(0, 0, 0); + + /** Hits divided by lookups, or 0 when nothing has been looked up yet. */ + public double hitRate() { + long total = hits + misses; + return total == 0 ? 0 : (double) hits / total; + } +} diff --git a/flash-extensions/flash-ext-cache-redis/docs/README.md b/flash-extensions/flash-ext-cache-redis/docs/README.md new file mode 100644 index 0000000..3723782 --- /dev/null +++ b/flash-extensions/flash-ext-cache-redis/docs/README.md @@ -0,0 +1,39 @@ +# flash-ext-cache-redis — planned + +Not implemented. This directory holds the design so the decision is written down rather than +rediscovered; there is deliberately **no module, no pom and no source**, because an empty module +that builds an empty jar is dead weight in the reactor and in everyone's dependency tree. + +Add it when there is a second replica that actually needs shared state. + +## What it would implement + +`CacheManager` and `Cache` from [`flash-ext-cache-core`](../../flash-ext-cache-core/docs/README.md), +so switching backend is an install-line change: + +```java +.install(new RedisCacheExtension(RedisConfig.of("redis://localhost:6379"))) +``` + +## The part that is not a drop-in + +`flash-ext-cache-caffeine` cannot fail. A networked cache can, and that changes the contract in +ways an adapter cannot hide: + +- **`get(key, loader)` can fail before reaching the loader.** The honest default is to fall + through to the loader and serve the value uncached, so Redis being down degrades throughput + rather than taking the application with it. That has to be a decision, not an accident. +- **Values must be serialized.** Caffeine stores references. A `byte[]` codec belongs in the spec, + and the natural default is whatever `flash-ext-jackson` is already configured with. +- **`invalidateAll()` is not free.** Against a shared keyspace it is either a scan or a key + prefix per cache name. The prefix is the right answer, and it means cache names become part of + the wire contract. +- **Stats are per-client, not per-cache.** Hit rate stays meaningful; eviction count does not, + because Redis evicts on its own policy. + +## Why it is not built yet + +Nothing in the codebase has two replicas sharing cache state. Building it now would mean choosing +a client library, a serialization format and a failure policy with no real usage to check them +against — and the failure policy in particular is the kind of decision that is wrong until a +production incident tells you otherwise. diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index 70452eb..17a9e53 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -27,10 +27,27 @@ flash-ext-data-core flash-ext-data-jdbc flash-ext-data-hibernate + flash-ext-cache-core + flash-ext-cache-caffeine + + dev.relism + flash-ext-cache-core + ${project.version} + + + dev.relism + flash-ext-cache-caffeine + ${project.version} + + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} + dev.relism flash-testing diff --git a/pom.xml b/pom.xml index aaa7c7e..a57d9f4 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,7 @@ 2.18.0 1.37 5.11.0 + 3.1.8 3.6.0 @@ -67,6 +68,16 @@ flash-testing ${project.version} + + dev.relism + flash-ext-cache-core + ${project.version} + + + dev.relism + flash-ext-cache-caffeine + ${project.version} + dev.relism flash-ext-jackson