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>
This commit is contained in:
Zakaria El Orche
2026-09-09 12:20:33 +00:00
co-authored by Claude Opus 5
parent fe8c6ed162
commit f68e661296
16 changed files with 1006 additions and 2 deletions
@@ -29,6 +29,15 @@
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</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>
<groupId>org.junit.jupiter</groupId>
<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;
}
/** 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<Class<?>> 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);