Files
Flash5/flash-extensions/flash-ext-jackson-core
Zakaria El OrcheandClaude Opus 5 491553e9f5 fix(ext-jackson): a constraint says what it wants in its own words
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>
2026-09-23 14:51:16 +00:00
..

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.