fix(ext-jackson): a constraint says what it wants in its own words #22
@@ -38,6 +38,18 @@ public record NewUser(@NotBlank @Size(max = 80) String name, @Email String email
|
|||||||
Supported: `@NotNull`, `@NotBlank`, `@NotEmpty`, `@Size`, `@Min`, `@Max`, `@Email`, `@Pattern`.
|
Supported: `@NotNull`, `@NotBlank`, `@NotEmpty`, `@Size`, `@Min`, `@Max`, `@Email`, `@Pattern`.
|
||||||
Jakarta semantics: only `@NotNull` rejects null, every other constraint passes it.
|
Jakarta semantics: only `@NotNull` rejects null, every other constraint passes it.
|
||||||
|
|
||||||
|
A failure reads `<field> <message>`, and the message is the constraint's own when it sets one —
|
||||||
|
which is how the person who sent the request is told something better than a regex:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Pattern(regexp = "[A-Za-z0-9_][A-Za-z0-9_.-]{0,254}", message = "uses up to 255 letters, digits, _, . and -")
|
||||||
|
String key
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"error": "key uses up to 255 letters, digits, _, . and -", "status": 422}
|
||||||
|
```
|
||||||
|
|
||||||
The constraints of a type are compiled the first time it is seen and kept in a `ClassValue`,
|
The constraints of a type are compiled the first time it is seen and kept in a `ClassValue`,
|
||||||
beside the class itself — no map, no lock. A check reads the field through an exact-signature
|
beside the class itself — no map, no lock. A check reads the field through an exact-signature
|
||||||
`MethodHandle`: no boxing, no argument array, no iterator, and nothing allocated at all unless
|
`MethodHandle`: no boxing, no argument array, no iterator, and nothing allocated at all unless
|
||||||
|
|||||||
+24
-11
@@ -178,25 +178,28 @@ public final class Validator {
|
|||||||
Class<?> type = field.getType();
|
Class<?> type = field.getType();
|
||||||
MethodHandle ref = type.isPrimitive() ? null : asReference(getter);
|
MethodHandle ref = type.isPrimitive() ? null : asReference(getter);
|
||||||
|
|
||||||
if (field.isAnnotationPresent(NotNull.class) && ref != null)
|
NotNull notNull = field.getAnnotation(NotNull.class);
|
||||||
checks.add(Check.reference(Check.NOT_NULL, name, "must not be null", ref));
|
if (notNull != null && ref != null)
|
||||||
|
checks.add(Check.reference(Check.NOT_NULL, name, said(notNull.message(), "must not be null"), ref));
|
||||||
|
|
||||||
if (field.isAnnotationPresent(NotBlank.class) && ref != null)
|
NotBlank notBlank = field.getAnnotation(NotBlank.class);
|
||||||
checks.add(Check.reference(Check.NOT_BLANK, name, "must not be blank", ref));
|
if (notBlank != null && ref != null)
|
||||||
|
checks.add(Check.reference(Check.NOT_BLANK, name, said(notBlank.message(), "must not be blank"), ref));
|
||||||
|
|
||||||
if (field.isAnnotationPresent(NotEmpty.class) && ref != null)
|
NotEmpty notEmpty = field.getAnnotation(NotEmpty.class);
|
||||||
checks.add(Check.reference(Check.NOT_EMPTY, name, "must not be empty", ref));
|
if (notEmpty != null && ref != null)
|
||||||
|
checks.add(Check.reference(Check.NOT_EMPTY, name, said(notEmpty.message(), "must not be empty"), ref));
|
||||||
|
|
||||||
Size size = field.getAnnotation(Size.class);
|
Size size = field.getAnnotation(Size.class);
|
||||||
if (size != null && ref != null)
|
if (size != null && ref != null)
|
||||||
checks.add(Check.size(name, sizeMessage(size), ref, size.min(), size.max()));
|
checks.add(Check.size(name, said(size.message(), sizeMessage(size)), ref, size.min(), size.max()));
|
||||||
|
|
||||||
Min min = field.getAnnotation(Min.class);
|
Min min = field.getAnnotation(Min.class);
|
||||||
Max max = field.getAnnotation(Max.class);
|
Max max = field.getAnnotation(Max.class);
|
||||||
if (min != null || max != null) {
|
if (min != null || max != null) {
|
||||||
long lo = min != null ? min.value() : Long.MIN_VALUE;
|
long lo = min != null ? min.value() : Long.MIN_VALUE;
|
||||||
long hi = max != null ? max.value() : Long.MAX_VALUE;
|
long hi = max != null ? max.value() : Long.MAX_VALUE;
|
||||||
String message = rangeMessage(min, max);
|
String message = said(min != null ? min.message() : max.message(), rangeMessage(min, max));
|
||||||
if (isIntegralPrimitive(type)) {
|
if (isIntegralPrimitive(type)) {
|
||||||
checks.add(Check.rangePrimitive(name, message, asLong(getter), lo, hi));
|
checks.add(Check.rangePrimitive(name, message, asLong(getter), lo, hi));
|
||||||
} else if (Number.class.isAssignableFrom(type) && ref != null) {
|
} else if (Number.class.isAssignableFrom(type) && ref != null) {
|
||||||
@@ -204,14 +207,15 @@ public final class Validator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.isAnnotationPresent(Email.class) && ref != null)
|
Email email = field.getAnnotation(Email.class);
|
||||||
checks.add(Check.reference(Check.EMAIL, name, "must be a well-formed email address", ref));
|
if (email != null && ref != null)
|
||||||
|
checks.add(Check.reference(Check.EMAIL, name, said(email.message(), "must be a well-formed email address"), ref));
|
||||||
|
|
||||||
Pattern pattern = field.getAnnotation(Pattern.class);
|
Pattern pattern = field.getAnnotation(Pattern.class);
|
||||||
if (pattern != null && ref != null) {
|
if (pattern != null && ref != null) {
|
||||||
// ponytail: the one allocating check — Pattern.matcher() per call. The regex itself is
|
// 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.
|
// 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,
|
checks.add(Check.pattern(name, said(pattern.message(), "must match " + pattern.regexp()), ref,
|
||||||
java.util.regex.Pattern.compile(pattern.regexp())));
|
java.util.regex.Pattern.compile(pattern.regexp())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,6 +232,15 @@ public final class Validator {
|
|||||||
return getter.asType(MethodType.methodType(long.class, Object.class));
|
return getter.asType(MethodType.methodType(long.class, Object.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a failure says: the constraint's own {@code message} when it sets one, and otherwise a
|
||||||
|
* plain description of the rule. Jakarta's defaults are bundle keys in braces, and a key is
|
||||||
|
* not something to put in front of whoever sent the request.
|
||||||
|
*/
|
||||||
|
private static String said(String message, String otherwise) {
|
||||||
|
return message == null || message.isBlank() || message.startsWith("{") ? otherwise : message;
|
||||||
|
}
|
||||||
|
|
||||||
private static String sizeMessage(Size size) {
|
private static String sizeMessage(Size size) {
|
||||||
if (size.min() == 0) return "size must be at most " + size.max();
|
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();
|
if (size.max() == Integer.MAX_VALUE) return "size must be at least " + size.min();
|
||||||
|
|||||||
+11
@@ -114,4 +114,15 @@ class ValidatorTest {
|
|||||||
assertTrue(validator.isEmpty());
|
assertTrue(validator.isEmpty());
|
||||||
assertDoesNotThrow(() -> validator.verify(new Plain(null)));
|
assertDoesNotThrow(() -> validator.verify(new Plain(null)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
record Keyed(@jakarta.validation.constraints.Pattern(regexp = "[a-z.]+",
|
||||||
|
message = "uses lowercase letters and dots") String key) {}
|
||||||
|
|
||||||
|
@org.junit.jupiter.api.Test
|
||||||
|
void a_constraint_says_what_it_wants_in_its_own_words() {
|
||||||
|
ValidationException refused = org.junit.jupiter.api.Assertions.assertThrows(
|
||||||
|
ValidationException.class, () -> Validator.check(new Keyed("Not A Key")));
|
||||||
|
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals("key uses lowercase letters and dots", refused.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user