Files
Flash5/flash-extensions/flash-ext-validation/docs/README.md
T
Zakaria El OrcheandClaude Opus 5 f68e661296 feat(ext-validation): add request validation with compiled constraints
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 <noreply@anthropic.com>
2026-09-09 12:20:33 +00:00

144 lines
5.3 KiB
Markdown

# 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
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-validation</artifactId>
<version>${flash.version}</version>
</dependency>
```
## 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.