Feature/ext validation/request validation #15

Merged
Relism merged 10 commits from feature/ext-validation/request-validation into master 2026-09-09 14:21:17 +00:00
16 changed files with 1006 additions and 2 deletions
Showing only changes of commit f68e661296 - Show all commits
+1 -1
View File
@@ -38,7 +38,7 @@ Format: `<type>(<scope>): <short description>`
Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`, 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-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: Examples:
``` ```
@@ -29,6 +29,15 @@
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
</dependency> </dependency>
<!--
Annotations only, and optional: when present the generated schema mirrors the
constraints; when absent ConstraintHints is never loaded and nothing changes.
-->
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<optional>true</optional>
</dependency>
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <artifactId>junit-jupiter</artifactId>
@@ -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.
*
* <p>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<String, Object> 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<String> supported() {
return List.of("@NotNull", "@NotBlank", "@NotEmpty", "@Size", "@Min", "@Max", "@Email", "@Pattern");
}
}
@@ -365,6 +365,9 @@ public final class OpenApiBuilder {
return null; 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 class SchemaRegistry {
private static final Set<Class<?>> SIMPLE = Set.of( private static final Set<Class<?>> SIMPLE = Set.of(
String.class, CharSequence.class, String.class, CharSequence.class,
@@ -476,8 +479,14 @@ public final class OpenApiBuilder {
if (jp.access() == Access.WRITE_ONLY) property.put("writeOnly", true); 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); 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); if (!properties.isEmpty()) out.put("properties", properties);
@@ -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
<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.
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-validation</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<!--
Annotations only (~90 KB). Hibernate Validator's engine is deliberately absent: it
resolves constraints reflectively per call and pulls ~2 MB plus EL. This module
compiles the same annotations into a flat check table once per class instead.
-->
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
<!-- Only needed for Validation.body(...); check(...) works without it. -->
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-jackson</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<scope>test</scope>
</dependency>
<!-- Interop only: proves constraints reach the published schema. -->
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-openapi</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -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.
*
* <p>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);
}
}
@@ -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)}.
*
* <pre>{@code
* CreateUser dto = validation.body(req, CreateUser.class); // parse + verify
* }</pre>
*
* <p>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<Validator> 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> T body(Request request, Class<T> 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> 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);
}
}
@@ -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.
*
* <p>Allocated only on failure — a passing validation constructs nothing.
*/
public final class ValidationException extends HttpException {
private final transient List<Violation> violations;
ValidationException(List<Violation> violations) {
super(422, describe(violations));
this.violations = List.copyOf(violations);
}
/** The individual failures, in field declaration order. */
public List<Violation> violations() {
return violations;
}
private static String describe(List<Violation> 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) {}
}
@@ -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.
*
* <pre>{@code
* FlashApp.create(8080)
* .install(new JacksonExtension())
* .install(new ValidationExtension())
* .scan("dev.example.api");
* }</pre>
*
* <p>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.
*
* <p>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)));
}
}
@@ -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.
*
* <p>{@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<ValidationException.Violation> 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.
*
* <p>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.
*
* <p>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<Check> 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<Check> 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();
}
}
@@ -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\"]");
}
}
@@ -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");
}
}
@@ -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<String> 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)));
}
}
+6
View File
@@ -24,6 +24,7 @@
<module>flash-ext-limiter</module> <module>flash-ext-limiter</module>
<module>flash-ext-web-bundler</module> <module>flash-ext-web-bundler</module>
<module>flash-ext-mcp</module> <module>flash-ext-mcp</module>
<module>flash-ext-validation</module>
<module>flash-ext-data-core</module> <module>flash-ext-data-core</module>
<module>flash-ext-data-jdbc</module> <module>flash-ext-data-jdbc</module>
<module>flash-ext-data-hibernate</module> <module>flash-ext-data-hibernate</module>
@@ -31,6 +32,11 @@
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-validation</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId> <artifactId>flash-testing</artifactId>
+11
View File
@@ -38,6 +38,7 @@
<maven.versions.plugin.version>2.18.0</maven.versions.plugin.version> <maven.versions.plugin.version>2.18.0</maven.versions.plugin.version>
<jmh.version>1.37</jmh.version> <jmh.version>1.37</jmh.version>
<junit.version>5.11.0</junit.version> <junit.version>5.11.0</junit.version>
<jakarta.validation.version>3.1.1</jakarta.validation.version>
<build.helper.plugin.version>3.6.0</build.helper.plugin.version> <build.helper.plugin.version>3.6.0</build.helper.plugin.version>
</properties> </properties>
@@ -67,6 +68,16 @@
<artifactId>flash-testing</artifactId> <artifactId>flash-testing</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-validation</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>${jakarta.validation.version}</version>
</dependency>
<dependency> <dependency>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-ext-jackson</artifactId> <artifactId>flash-ext-jackson</artifactId>