feat(ext-jackson): one module per data format, bodies checked on the way in
flash-ext-jackson and flash-ext-validation become three modules: - flash-ext-jackson-core: the Codec (one mapper, body/write/writeView), JacksonHandler, the outbound marshalling, and the constraint engine that used to be flash-ext-validation - flash-ext-jackson-json: Json, JsonExtension, JsonHandler - flash-ext-jackson-xml: Xml, XmlExtension, XmlHandler Every Jackson format is the same databind model behind a different factory, so the annotations, the constraints and the published schema are the same for all of them: only the mapper and the content type differ, and that is all a format module says. A route picks its format by the handler it extends — there is no negotiation and nothing to configure. A typed body is now always verified against its own type's jakarta constraints, whatever the format: malformed is a 400, a broken constraint is a 422, and neither reaches the handler. The validator was already allocation-free and stays so; validating is no longer something an application remembers to do. bodyFrom is gone. body has the streaming semantics, because the request's stream is reused per connection while bytes() allocates the whole body: one name, the path that does not allocate. JacksonExtension is JsonExtension, and autoJson() is auto(). The root POM now manages every module of this build, so anything composing Flash imports it once and never names a version again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ef4740f26d
commit
adcd6376b6
@@ -0,0 +1,66 @@
|
||||
# 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-json),
|
||||
[`flash-ext-jackson-xml`](../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 `ObjectMapper`s. 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:
|
||||
|
||||
```java
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
```java
|
||||
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.
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-jackson-core</artifactId>
|
||||
<name>flash-ext-jackson-core</name>
|
||||
<description>What every Jackson data format shares: the codec, the body handler and the constraints a body is checked against.</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
<!-- The constraint annotations a body is checked against; no implementation, no transitives. -->
|
||||
<dependency>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.Response;
|
||||
|
||||
/**
|
||||
* One Jackson mapper, in the shape a handler needs it.
|
||||
*
|
||||
* <p>Every Jackson data format is the same databind model behind a different factory, so this is
|
||||
* the whole of what a format module has to say: which mapper, and which content type it writes.
|
||||
* What the annotations mean, what a body is checked against and how a type is described are the
|
||||
* same for all of them.
|
||||
*
|
||||
* <p>Retrieve it once at boot — {@code @Inject private Json json;} — and call it on the hot path.
|
||||
* The underlying {@link ObjectMapper} is thread-safe once configured.
|
||||
*/
|
||||
public abstract class Codec {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final ContentType contentType;
|
||||
|
||||
protected Codec(ObjectMapper mapper, ContentType contentType) {
|
||||
this.mapper = mapper;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the request body as {@code type} and verifies its constraints.
|
||||
*
|
||||
* <p>Read straight off the request's stream, which Flash reuses per connection: nothing
|
||||
* buffers the body to hand it over. A type that declares no constraints is not checked at all.
|
||||
*
|
||||
* @throws HttpException 400 if the body cannot be parsed as {@code type}
|
||||
* @throws ValidationException 422 if it parses but violates a constraint
|
||||
*/
|
||||
public <T> T body(Request request, Class<T> type) throws Exception {
|
||||
T value;
|
||||
try {
|
||||
value = mapper.readValue(request.body().stream(), type);
|
||||
} catch (JsonProcessingException malformed) {
|
||||
throw HttpException.badRequest("Invalid request body: " + malformed.getOriginalMessage());
|
||||
}
|
||||
Validator.of(type).verify(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Serializes {@code value} and sets this codec's content type on the response. */
|
||||
public String write(Response response, Object value) throws Exception {
|
||||
response.type(contentType);
|
||||
return mapper.writeValueAsString(value);
|
||||
}
|
||||
|
||||
/** Like {@link #write}, restricted to the fields visible under a Jackson {@code @JsonView}. */
|
||||
public String writeView(Response response, Object value, Class<?> view) throws Exception {
|
||||
response.type(contentType);
|
||||
return mapper.writerWithView(view).writeValueAsString(value);
|
||||
}
|
||||
|
||||
/** What this codec writes, for a handler that sets the response type itself. */
|
||||
public ContentType contentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
/** The mapper itself, for everything these methods do not cover. */
|
||||
public ObjectMapper mapper() {
|
||||
return mapper;
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import dev.relism.flash.models.BodyHandler;
|
||||
import dev.relism.flash.models.Request;
|
||||
|
||||
/**
|
||||
* A handler whose body one Jackson format parses and whose constraints are checked before it
|
||||
* arrives.
|
||||
*
|
||||
* <p>Format modules extend this and name their codec — {@code JsonHandler}, {@code XmlHandler}.
|
||||
* An application extends those, never this one.
|
||||
*
|
||||
* @param <B> the body type, which is also what the published OpenAPI document describes
|
||||
*/
|
||||
public abstract class JacksonHandler<B> extends BodyHandler<B> {
|
||||
|
||||
private final Class<B> type = bodyType();
|
||||
|
||||
protected JacksonHandler() {
|
||||
if (type == null) {
|
||||
throw new IllegalStateException(getClass().getSimpleName()
|
||||
+ " extends a body handler without naming its body type — write it as Handler<YourBody>");
|
||||
}
|
||||
}
|
||||
|
||||
/** The format this handler speaks. Injected by the subclass, resolved once at boot. */
|
||||
protected abstract Codec codec();
|
||||
|
||||
@Override
|
||||
protected final B body(Request request) throws Exception {
|
||||
return codec().body(request, type);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
/**
|
||||
* Turns whatever a handler returns into a serialized body.
|
||||
*
|
||||
* <p>Pass-through for what is already a response: {@code null}, a {@link Response}, a
|
||||
* {@code byte[]} or a {@link CharSequence}. Everything else is serialized straight to bytes.
|
||||
*/
|
||||
public final class Marshalling {
|
||||
|
||||
private Marshalling() {}
|
||||
|
||||
public static Middleware of(ObjectMapper mapper, ContentType contentType) {
|
||||
return next -> (req, res) -> {
|
||||
Object out = next.handle(req, res);
|
||||
if (out == null || out instanceof Response || out instanceof byte[] || out instanceof CharSequence) return out;
|
||||
|
||||
res.type(contentType);
|
||||
try {
|
||||
return mapper.writeValueAsBytes(out);
|
||||
} catch (JsonProcessingException failure) {
|
||||
throw new IllegalStateException("Could not serialize " + out.getClass().getName(), failure);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
|
||||
@@ -14,7 +14,7 @@ public final class ValidationException extends HttpException {
|
||||
|
||||
private final transient List<Violation> violations;
|
||||
|
||||
ValidationException(List<Violation> violations) {
|
||||
public ValidationException(List<Violation> violations) {
|
||||
super(422, describe(violations));
|
||||
this.violations = List.copyOf(violations);
|
||||
}
|
||||
|
||||
+27
-1
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Max;
|
||||
@@ -31,12 +31,38 @@ public final class Validator {
|
||||
|
||||
private static final Check[] NONE = new Check[0];
|
||||
|
||||
/**
|
||||
* One compiled validator per type, kept beside the class itself: no map lookup, no lock, and
|
||||
* the entry is collected with the class rather than pinning it.
|
||||
*/
|
||||
private static final ClassValue<Validator> COMPILED = new ClassValue<>() {
|
||||
@Override protected Validator computeValue(Class<?> type) {
|
||||
return compile(type);
|
||||
}
|
||||
};
|
||||
|
||||
private final Check[] checks;
|
||||
|
||||
private Validator(Check[] checks) {
|
||||
this.checks = checks;
|
||||
}
|
||||
|
||||
/** The constraints of {@code type}, compiled the first time it is seen and reused after. */
|
||||
public static Validator of(Class<?> type) {
|
||||
return COMPILED.get(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a value against its own type's constraints.
|
||||
*
|
||||
* @return {@code value}, so it can be used inline
|
||||
* @throws ValidationException 422, listing every failure
|
||||
*/
|
||||
public static <T> T check(T value) {
|
||||
of(value.getClass()).verify(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** True when the type declares no constraints at all — {@link #verify} is then a no-op. */
|
||||
public boolean isEmpty() {
|
||||
return checks.length == 0;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Max;
|
||||
|
||||
Reference in New Issue
Block a user