diff --git a/flash-extensions/flash-ext-jackson-core/README.md b/flash-extensions/flash-ext-jackson-core/README.md index 2ad8817..baff05d 100644 --- a/flash-extensions/flash-ext-jackson-core/README.md +++ b/flash-extensions/flash-ext-jackson-core/README.md @@ -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`. Jakarta semantics: only `@NotNull` rejects null, every other constraint passes it. +A failure reads ` `, 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`, 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 diff --git a/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Validator.java b/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Validator.java index 889ca8f..928ed10 100644 --- a/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Validator.java +++ b/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Validator.java @@ -178,25 +178,28 @@ public final class Validator { 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)); + NotNull notNull = field.getAnnotation(NotNull.class); + 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) - checks.add(Check.reference(Check.NOT_BLANK, name, "must not be blank", ref)); + NotBlank notBlank = field.getAnnotation(NotBlank.class); + 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) - checks.add(Check.reference(Check.NOT_EMPTY, name, "must not be empty", ref)); + NotEmpty notEmpty = field.getAnnotation(NotEmpty.class); + 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); 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); 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); + String message = said(min != null ? min.message() : max.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) { @@ -204,14 +207,15 @@ public final class Validator { } } - if (field.isAnnotationPresent(Email.class) && ref != null) - checks.add(Check.reference(Check.EMAIL, name, "must be a well-formed email address", ref)); + Email email = field.getAnnotation(Email.class); + 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); 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, + checks.add(Check.pattern(name, said(pattern.message(), "must match " + pattern.regexp()), ref, java.util.regex.Pattern.compile(pattern.regexp()))); } } @@ -228,6 +232,15 @@ public final class Validator { 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) { 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(); diff --git a/flash-extensions/flash-ext-jackson-core/src/test/java/dev/relism/flash/ext/jackson/ValidatorTest.java b/flash-extensions/flash-ext-jackson-core/src/test/java/dev/relism/flash/ext/jackson/ValidatorTest.java index 736690f..619492a 100644 --- a/flash-extensions/flash-ext-jackson-core/src/test/java/dev/relism/flash/ext/jackson/ValidatorTest.java +++ b/flash-extensions/flash-ext-jackson-core/src/test/java/dev/relism/flash/ext/jackson/ValidatorTest.java @@ -114,4 +114,15 @@ class ValidatorTest { assertTrue(validator.isEmpty()); 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()); + } }