feat(core): a service a handler asks for, and a body typed in its signature
Two things every handler was writing by hand. @Inject on a field is filled inside bind, before onInit, once per handler at boot: the request path still reads a field. The service is looked up by the field's exact declared type; a static or final field is refused, and a type nothing provides fails the boot naming the field. onInit stays for what has to be computed, or for a service that may not be there. BodyHandler<B> puts the body type in the signature — handle(req, res, body) — and leaves reading it to the format. bodyTypeOf resolves that type argument through a whole chain of bases, so tooling can read off a class what a route takes. @Consumes says in which media type, inherited from the base class that implements the reading, and is descriptive: the router does not enforce it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8353033cb1
commit
ef4740f26d
+76
@@ -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);
|
||||
}
|
||||
}
|
||||
+39
@@ -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) {}
|
||||
}
|
||||
+216
@@ -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();
|
||||
}
|
||||
}
|
||||
+117
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user