From f68e66129612d19ca1cedc94c0a6978cce256302 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 9 Sep 2026 12:20:33 +0000 Subject: [PATCH] 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