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:
co-authored by
Claude Opus 5
parent
fe8c6ed162
commit
f68e661296
@@ -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>
|
||||
|
||||
+84
@@ -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");
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user