The compiler ignored the message a constraint declares and always wrote its own, so a failed @Pattern answered the caller with a regex. It now uses the annotation's message whenever one is set, and keeps the plain description for jakarta's default, which is a resource bundle key and not something to put in front of whoever sent the request. This is what makes moving a check out of a service and onto the type it belongs to cost nothing: the wording moves with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3.2 KiB
flash-ext-jackson-core
What every Jackson data format shares. Applications do not install this module directly: they
install a format — flash-ext-jackson-json,
flash-ext-jackson-xml — and get all of this with it.
Why there is a core at all
Every Jackson data format is the same databind model behind a different factory: XmlMapper,
YAMLMapper and CBORMapper are all ObjectMappers. So the annotations on a type, the
constraints its fields declare and the schema it publishes are the same whatever writes it. Only
the mapper and the content type differ, and that is all a format module has to say.
Codec
One mapper, in the shape a handler needs it.
body(Request, Class<T>) |
Parses the body and verifies its constraints |
write(Response, Object) |
Serializes and sets the format's content type |
writeView(Response, Object, Class<?>) |
The same, through a Jackson @JsonView |
mapper() |
The ObjectMapper itself, for everything else |
body reads straight off the request's stream, which Flash reuses per connection: the body is
never buffered into an array to be handed over. A malformed body is a 400, a body that breaks a
constraint is a 422, and neither reaches the handler.
Constraints
A body is checked against the jakarta.validation annotations its own type declares — nothing to
install, nothing to call:
public record NewUser(@NotBlank @Size(max = 80) String name, @Email String email, @Min(18) int age) {}
Supported: @NotNull, @NotBlank, @NotEmpty, @Size, @Min, @Max, @Email, @Pattern.
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:
@Pattern(regexp = "[A-Za-z0-9_][A-Za-z0-9_.-]{0,254}", message = "uses up to 255 letters, digits, _, . and -")
String key
{"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
something fails. A type that declares no constraints compiles to a validator that does nothing.
Verify a value built by hand with Validator.check(value).
flash-ext-openapi reads the same annotations to publish minLength, maximum, pattern and
the required fields, so a rule is written once and both enforced and documented.
Writing a format module
public final class Yaml extends Codec {
public Yaml(YAMLMapper mapper) { super(mapper, ContentType.TEXT_YAML); }
}
@Consumes(ContentType.TEXT_YAML)
public abstract class YamlHandler<B> extends JacksonHandler<B> {
@Inject private Yaml yaml;
@Override protected Codec codec() { return yaml; }
}
Plus an extension that provides the codec and, for outbound bodies,
Marshalling.of(mapper, contentType). That is the whole of it.