feat: typed bodies, injected services, one Jackson module per format #20
@@ -9,7 +9,9 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
|
||||
|---|---|
|
||||
| `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model |
|
||||
| `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses |
|
||||
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
|
||||
| `flash-extensions/flash-ext-jackson-core` | What every Jackson format shares: the codec, the body handler, the constraints a body is checked against |
|
||||
| `flash-extensions/flash-ext-jackson-json` | JSON bodies and responses |
|
||||
| `flash-extensions/flash-ext-jackson-xml` | XML bodies and responses |
|
||||
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
||||
| `flash-extensions/flash-ext-security-core` | Security: authentication chain, annotations, sessions, OpenAPI |
|
||||
| `flash-extensions/flash-ext-security-oidc` | OpenID Connect: bearer tokens, code flow + PKCE |
|
||||
@@ -23,7 +25,6 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
|
||||
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
||||
| `flash-extensions/flash-ext-vite` | Vite frontend: dev server in DEV, the built SPA from the jar otherwise |
|
||||
| `flash-extensions/flash-ext-vite-maven-plugin` | Builds the Vite frontend into the jar during `mvn package` |
|
||||
| `flash-extensions/flash-ext-validation` | Request validation — jakarta constraints, compiled once per type |
|
||||
| `flash-extensions/flash-ext-scheduler` | Interval and cron background jobs on virtual threads |
|
||||
| `flash-extensions/flash-ext-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` |
|
||||
| `flash-extensions/flash-ext-cache-caffeine` | In-process cache backed by Caffeine |
|
||||
@@ -151,7 +152,9 @@ FlashApp.create(8080)
|
||||
```
|
||||
|
||||
See extension-specific READMEs for full details:
|
||||
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
|
||||
- [`flash-ext-jackson-core`](flash-extensions/flash-ext-jackson-core/README.md)
|
||||
- [`flash-ext-jackson-json`](flash-extensions/flash-ext-jackson-json/README.md)
|
||||
- [`flash-ext-jackson-xml`](flash-extensions/flash-ext-jackson-xml/README.md)
|
||||
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
|
||||
- [`flash-ext-security-core`](flash-extensions/flash-ext-security-core/docs/README.md)
|
||||
- [`flash-ext-security-oidc`](flash-extensions/flash-ext-security-oidc/docs/README.md)
|
||||
@@ -161,7 +164,6 @@ See extension-specific READMEs for full details:
|
||||
- [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
|
||||
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
|
||||
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
|
||||
- [`flash-ext-validation`](flash-extensions/flash-ext-validation/docs/README.md)
|
||||
- [`flash-ext-scheduler`](flash-extensions/flash-ext-scheduler/docs/README.md)
|
||||
- [`flash-ext-cache-caffeine`](flash-extensions/flash-ext-cache-caffeine/docs/README.md)
|
||||
- [`flash-testing`](flash-testing/docs/README.md)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# flash-ext-jackson-json
|
||||
|
||||
JSON bodies and JSON responses.
|
||||
|
||||
## Install
|
||||
|
||||
```java
|
||||
JsonExtension json = new JsonExtension();
|
||||
|
||||
FlashApp.create(8080)
|
||||
.install(json)
|
||||
.use(json.auto())
|
||||
.scan("com.acme.handlers")
|
||||
.startAndBlock();
|
||||
```
|
||||
|
||||
The default mapper discovers the modules on the classpath (Java Time among them) and writes dates
|
||||
as ISO strings; `new JsonExtension(mapper)` takes one of your own. `auto()` serializes whatever a
|
||||
handler returns, leaving alone what is already a response: `null`, a `Response`, a `byte[]` or a
|
||||
`CharSequence`.
|
||||
|
||||
## A handler with a body
|
||||
|
||||
The body type is the handler's type argument, and that is the whole declaration — it is also what
|
||||
the OpenAPI document describes and what the constraints are read from:
|
||||
|
||||
```java
|
||||
@POST("/users")
|
||||
public final class CreateUser extends JsonHandler<NewUser> {
|
||||
@Inject private UserService users;
|
||||
|
||||
@Override protected Object handle(Request req, Response res, NewUser body) {
|
||||
return users.create(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A malformed body never reaches it (400), nor does one that breaks a constraint (422).
|
||||
|
||||
## A handler that reads it itself
|
||||
|
||||
```java
|
||||
public final class Import extends RequestHandler {
|
||||
@Inject private Json json;
|
||||
|
||||
@Override public Object handle(Request req, Response res) throws Exception {
|
||||
return archive.store(json.body(req, Manifest.class));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Json` is the [`Codec`](../flash-ext-jackson-core) for `application/json`: `body`, `write`,
|
||||
`writeView`, `mapper`.
|
||||
|
||||
## Notes
|
||||
|
||||
- One mapper per application (or per scope), shared by every handler; `ObjectMapper` is
|
||||
thread-safe once configured.
|
||||
- Install `flash-ext-jackson-xml` beside this one when an application speaks both: a route picks
|
||||
its format by the handler it extends, not by negotiation.
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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-json</artifactId>
|
||||
<name>flash-ext-jackson-json</name>
|
||||
<description>JSON bodies and responses: the Json codec, JsonHandler and the marshalling middleware.</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- The constraints a body declares are described by the published document too. -->
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-openapi</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.ext.jackson.Codec;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
|
||||
/**
|
||||
* JSON in and out, checked against the body type's own constraints.
|
||||
*
|
||||
* <pre>{@code
|
||||
* public final class CreateItem extends RequestHandler {
|
||||
* @Inject private Json json;
|
||||
*
|
||||
* @Override public Object handle(Request req, Response res) throws Exception {
|
||||
* return items.create(json.body(req, NewItem.class));
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>A handler whose whole body is one type has nothing to write at all: see {@link JsonHandler}.
|
||||
*/
|
||||
public final class Json extends Codec {
|
||||
|
||||
public Json(ObjectMapper mapper) {
|
||||
super(mapper, ContentType.JSON);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import dev.relism.flash.ext.jackson.Marshalling;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
/**
|
||||
* JSON for an application: the {@link Json} codec, the mapper behind it, and the middleware that
|
||||
* serializes whatever a handler returns.
|
||||
*
|
||||
* <pre>{@code
|
||||
* JsonExtension json = new JsonExtension();
|
||||
* app.install(json).use(json.auto());
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The default mapper discovers the modules on the classpath (Java Time among them) and writes
|
||||
* dates as ISO strings. Hand it a mapper of your own to decide otherwise.
|
||||
*/
|
||||
public class JsonExtension implements FlashExtension {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public JsonExtension() {
|
||||
this(JsonMapper.builder()
|
||||
.findAndAddModules()
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build());
|
||||
}
|
||||
|
||||
public JsonExtension(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/** Serializes what a handler returns, unless it already returned a response, bytes or text. */
|
||||
public Middleware auto() {
|
||||
return Marshalling.of(mapper, ContentType.JSON);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.provide(Json.class, new Json(mapper));
|
||||
ctx.provide(ObjectMapper.class, mapper);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import dev.relism.flash.ext.jackson.Codec;
|
||||
import dev.relism.flash.ext.jackson.JacksonHandler;
|
||||
import dev.relism.flash.extension.Inject;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.routing.Consumes;
|
||||
|
||||
/**
|
||||
* A handler that takes a JSON body of one type.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @POST("/users")
|
||||
* public final class CreateUser extends JsonHandler<NewUser> {
|
||||
* @Inject private UserService users;
|
||||
*
|
||||
* @Override protected Object handle(Request req, Response res, NewUser body) {
|
||||
* return users.create(body);
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The body is read off the request stream, verified against the constraints its type declares,
|
||||
* and handed over. A malformed body is a 400, a body that breaks a constraint is a 422, and
|
||||
* neither ever reaches the handler. The published OpenAPI document describes the same type.
|
||||
*/
|
||||
@Consumes(ContentType.JSON)
|
||||
public abstract class JsonHandler<B> extends JacksonHandler<B> {
|
||||
|
||||
@Inject private Json json;
|
||||
|
||||
@Override protected final Codec codec() {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
+6
-7
@@ -1,6 +1,6 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import dev.relism.flash.ext.jackson.JacksonExtension;
|
||||
import dev.relism.flash.ext.jackson.json.JsonExtension;
|
||||
import dev.relism.flash.ext.openapi.APIResponse;
|
||||
import dev.relism.flash.ext.openapi.ApiOperation;
|
||||
import dev.relism.flash.ext.openapi.Content;
|
||||
@@ -24,7 +24,7 @@ import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
* describes them. Nothing registers this bridge — flash-ext-openapi picks the annotations up on
|
||||
* its own when they are on the classpath.
|
||||
*/
|
||||
class ValidationOpenApiInteropTest {
|
||||
class ConstraintsInTheDocumentTest {
|
||||
|
||||
record Account(
|
||||
@NotBlank @Size(max = 40) String name,
|
||||
@@ -42,10 +42,9 @@ class ValidationOpenApiInteropTest {
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest app = FlashTest.of(configured -> {
|
||||
configured.install(new JacksonExtension());
|
||||
configured.install(new ValidationExtension());
|
||||
configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0"));
|
||||
configured.scan("dev.relism.flash.ext.validation");
|
||||
configured.install(new JsonExtension());
|
||||
configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0"));
|
||||
configured.scan("dev.relism.flash.ext.jackson.json");
|
||||
});
|
||||
|
||||
@Test
|
||||
|
||||
+6
-7
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
@@ -16,26 +16,25 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JacksonExtensionTest {
|
||||
class JsonExtensionTest {
|
||||
|
||||
@Test
|
||||
void configure_registers_json_mapper_and_middleware() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JacksonExtension ext = new JacksonExtension(mapper);
|
||||
JsonExtension ext = new JsonExtension(mapper);
|
||||
|
||||
ext.configure(null, ctx);
|
||||
ctx.complete();
|
||||
|
||||
assertNotNull(ctx.require(Json.class));
|
||||
assertNotNull(ctx.require(JacksonMiddleware.class));
|
||||
assertSame(mapper, ctx.require(ObjectMapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoJson_factory_delegates_to_middleware_policy() throws Exception {
|
||||
void auto_marshals_what_a_handler_returns() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JacksonExtension ext = new JacksonExtension(mapper);
|
||||
JsonExtension ext = new JsonExtension(mapper);
|
||||
RequestHandler next = new RequestHandler() {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
@@ -43,7 +42,7 @@ class JacksonExtensionTest {
|
||||
}
|
||||
};
|
||||
RequestHandler wrapped = new RequestHandler() {
|
||||
private final SimpleHandler.FunctionalHandler delegate = ext.autoJson().wrap(next);
|
||||
private final SimpleHandler.FunctionalHandler delegate = ext.auto().wrap(next);
|
||||
|
||||
@Override
|
||||
public Object handle(Request request, Response response) throws Exception {
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.Http1HeaderMap;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestLine;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JsonHandlerTest {
|
||||
|
||||
public record NewUser(String name) {}
|
||||
|
||||
static final class Create extends JsonHandler<NewUser> {
|
||||
@Override protected Object handle(Request request, Response response, NewUser body) {
|
||||
return body.name();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("rawtypes")
|
||||
static final class Untyped extends JsonHandler {
|
||||
@Override protected Object handle(Request request, Response response, Object body) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theBodyArrivesParsedAsTheTypeTheHandlerDeclares() throws Exception {
|
||||
Create handler = new Create();
|
||||
handler.bind(context());
|
||||
|
||||
Object answer = handler.handle(request("{\"name\":\"alice\"}"), new Response(200, ContentType.JSON));
|
||||
|
||||
assertEquals("alice", answer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMalformedBodyIsTheUsualBadRequest() throws Exception {
|
||||
Create handler = new Create();
|
||||
handler.bind(context());
|
||||
|
||||
dev.relism.flash.exceptions.HttpException refused = assertThrows(dev.relism.flash.exceptions.HttpException.class,
|
||||
() -> handler.handle(request("not json"), new Response(200, ContentType.JSON)));
|
||||
|
||||
assertEquals(400, refused.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHandlerThatNeverNamedItsBodyTypeIsRefusedAtBoot() {
|
||||
IllegalStateException refused = assertThrows(IllegalStateException.class, Untyped::new);
|
||||
|
||||
assertTrue(refused.getMessage().contains("Handler<YourBody>"), refused.getMessage());
|
||||
}
|
||||
|
||||
private static FlashContext context() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ctx.provide(Json.class, new Json(new ObjectMapper()));
|
||||
ctx.complete();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private static Request request(String body) {
|
||||
return new Request(new RequestLine(HttpMethod.POST,
|
||||
new FastPathViews.StringByteView("/users"), null,
|
||||
new FastPathViews.StringByteView("HTTP/1.1"), new Http1HeaderMap()),
|
||||
body.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
+3
-8
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonView;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -37,16 +37,11 @@ class JsonTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void bodyFrom_parses_stream_and_maps_bad_payload_to_http_400() throws Exception {
|
||||
void a_truncated_body_is_a_bad_request_too() throws Exception {
|
||||
Json json = new Json(new ObjectMapper());
|
||||
|
||||
Request ok = request("{\"id\":\"u2\",\"name\":\"bob\"}");
|
||||
UserDto dto = json.bodyFrom(ok, UserDto.class);
|
||||
assertEquals("u2", dto.id);
|
||||
assertEquals("bob", dto.name);
|
||||
HttpException ex = assertThrows(HttpException.class, () -> json.body(request("["), UserDto.class));
|
||||
|
||||
Request bad = request("[");
|
||||
HttpException ex = assertThrows(HttpException.class, () -> json.bodyFrom(bad, UserDto.class));
|
||||
assertEquals(400, ex.status());
|
||||
}
|
||||
|
||||
|
||||
+13
-11
@@ -1,6 +1,8 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.ext.jackson.Marshalling;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import dev.relism.flash.models.SimpleHandler;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.models.Request;
|
||||
@@ -16,13 +18,13 @@ import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JacksonMiddlewareTest {
|
||||
class MarshallingTest {
|
||||
|
||||
private static final Request REQ = null;
|
||||
|
||||
@Test
|
||||
void autoJson_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
|
||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
||||
void marshalling_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
|
||||
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
||||
RequestHandler wrapped = wrap(mw, new UserDto("u1", "alice"));
|
||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
||||
|
||||
@@ -34,8 +36,8 @@ class JacksonMiddlewareTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoJson_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
|
||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
||||
void marshalling_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
|
||||
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
||||
|
||||
Response payloadResponse = new Response(201, ContentType.TEXT_PLAIN).body("ok");
|
||||
RequestHandler wrappedResponse = wrap(mw, payloadResponse);
|
||||
@@ -55,17 +57,17 @@ class JacksonMiddlewareTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoJson_wraps_serialization_errors_as_illegal_state() {
|
||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
||||
void marshalling_wraps_serialization_errors_as_illegal_state() {
|
||||
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
||||
RequestHandler wrapped = wrap(mw, new CyclicDto());
|
||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
||||
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> wrapped.handle(REQ, res));
|
||||
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
||||
assertTrue(ex.getMessage().startsWith("Failed to serialize handler result as JSON:"));
|
||||
assertTrue(ex.getMessage().startsWith("Could not serialize"));
|
||||
}
|
||||
|
||||
private static RequestHandler wrap(JacksonMiddleware mw, Object fixedReturn) {
|
||||
private static RequestHandler wrap(Middleware mw, Object fixedReturn) {
|
||||
RequestHandler next = new RequestHandler() {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
@@ -73,7 +75,7 @@ class JacksonMiddlewareTest {
|
||||
}
|
||||
};
|
||||
return new RequestHandler() {
|
||||
private final SimpleHandler.FunctionalHandler delegate = mw.autoJson().wrap(next);
|
||||
private final SimpleHandler.FunctionalHandler delegate = mw.wrap(next);
|
||||
|
||||
@Override
|
||||
public Object handle(Request request, Response response) throws Exception {
|
||||
|
||||
+5
-7
@@ -1,6 +1,5 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import dev.relism.flash.ext.jackson.JacksonExtension;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Min;
|
||||
@@ -12,19 +11,18 @@ import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/** The whole path: JSON in, constraints checked, status out — with no error handling wired up. */
|
||||
class ValidationRoutesTest {
|
||||
class ValidatedBodyTest {
|
||||
|
||||
record CreateUser(@NotBlank @Size(max = 8) String name, @Email String email, @Min(18) int age) {}
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest app = FlashTest.of(configured -> {
|
||||
configured.install(new JacksonExtension());
|
||||
configured.install(new ValidationExtension());
|
||||
configured.install(new JsonExtension());
|
||||
|
||||
configured.ctx().onReady(() -> {
|
||||
Validation validation = configured.ctx().require(Validation.class);
|
||||
Json json = configured.ctx().require(Json.class);
|
||||
configured.post("/users", (req, res) ->
|
||||
res.status(201).body("created:" + validation.body(req, CreateUser.class).name()));
|
||||
res.status(201).body("created:" + json.body(req, CreateUser.class).name()));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# flash-ext-jackson-xml
|
||||
|
||||
XML bodies and XML responses, over the same types as every other Jackson format.
|
||||
|
||||
```java
|
||||
XmlExtension xml = new XmlExtension();
|
||||
app.install(xml).use(xml.auto());
|
||||
```
|
||||
|
||||
```java
|
||||
@POST("/orders")
|
||||
public final class PlaceOrder extends XmlHandler<Order> {
|
||||
@Inject private OrderService orders;
|
||||
|
||||
@Override protected Object handle(Request req, Response res, Order body) {
|
||||
return orders.place(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Everything [`flash-ext-jackson-json`](../flash-ext-jackson-json) does, in XML: the body is read off
|
||||
the request stream, verified against the constraints its type declares, and handed over. A type
|
||||
annotated for JSON works here as is — `Order` can be a JSON body on one route and an XML body on
|
||||
another.
|
||||
|
||||
What is specific to XML is Jackson's own: `@JacksonXmlRootElement` for the root name,
|
||||
`@JacksonXmlProperty(isAttribute = true)` for an attribute rather than an element, and
|
||||
`@JacksonXmlElementWrapper` for how a list is wrapped. This module adds no annotations of its own.
|
||||
|
||||
Brings `jackson-dataformat-xml`, and with it Woodstox.
|
||||
+13
-21
@@ -10,42 +10,34 @@
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-validation</artifactId>
|
||||
<artifactId>flash-ext-jackson-xml</artifactId>
|
||||
<name>flash-ext-jackson-xml</name>
|
||||
<description>XML bodies and responses, over the same types and the same constraints as every other Jackson format.</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
<artifactId>flash-ext-jackson-core</artifactId>
|
||||
</dependency>
|
||||
<!--
|
||||
Annotations only (~90 KB). Hibernate Validator's engine is deliberately absent: it
|
||||
resolves constraints reflectively per call and pulls ~2 MB plus EL. This module
|
||||
compiles the same annotations into a flat check table once per class instead.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
</dependency>
|
||||
<!-- Only needed for Validation.body(...); check(...) works without it. -->
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
<optional>true</optional>
|
||||
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||
<artifactId>jackson-dataformat-xml</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Interop only: proves constraints reach the published schema. -->
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-openapi</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.relism.flash.ext.jackson.xml;
|
||||
|
||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||
import dev.relism.flash.ext.jackson.Codec;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
|
||||
/**
|
||||
* XML in and out, checked against the body type's own constraints.
|
||||
*
|
||||
* <p>The same databind model as every other Jackson format: a type is annotated once and can be
|
||||
* read as XML here and as JSON elsewhere in the same application. XML's own concerns — a root
|
||||
* element name, an attribute rather than an element, how a list is wrapped — are Jackson's
|
||||
* {@code @JacksonXml*} annotations on the type.
|
||||
*/
|
||||
public final class Xml extends Codec {
|
||||
|
||||
public Xml(XmlMapper mapper) {
|
||||
super(mapper, ContentType.XML);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.relism.flash.ext.jackson.xml;
|
||||
|
||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||
import dev.relism.flash.ext.jackson.Marshalling;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
/**
|
||||
* XML for an application: the {@link Xml} codec and the middleware that serializes what a handler
|
||||
* returns.
|
||||
*
|
||||
* <pre>{@code
|
||||
* XmlExtension xml = new XmlExtension();
|
||||
* app.install(xml).use(xml.auto());
|
||||
* }</pre>
|
||||
*
|
||||
* <p>Install it beside {@code JsonExtension} when an application speaks both: each provides its
|
||||
* own codec, and a route picks one by the handler it extends.
|
||||
*/
|
||||
public class XmlExtension implements FlashExtension {
|
||||
|
||||
private final XmlMapper mapper;
|
||||
|
||||
public XmlExtension() {
|
||||
this(XmlMapper.builder().findAndAddModules().build());
|
||||
}
|
||||
|
||||
public XmlExtension(XmlMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/** Serializes what a handler returns, unless it already returned a response, bytes or text. */
|
||||
public Middleware auto() {
|
||||
return Marshalling.of(mapper, ContentType.XML);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.provide(Xml.class, new Xml(mapper));
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.relism.flash.ext.jackson.xml;
|
||||
|
||||
import dev.relism.flash.ext.jackson.Codec;
|
||||
import dev.relism.flash.ext.jackson.JacksonHandler;
|
||||
import dev.relism.flash.extension.Inject;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.routing.Consumes;
|
||||
|
||||
/**
|
||||
* A handler that takes an XML body of one type.
|
||||
*
|
||||
* <p>Everything {@code JsonHandler} does, in XML: the body is read off the request stream,
|
||||
* verified against its type's constraints, and handed over. Both can live in the same
|
||||
* application, on different routes, over the same types.
|
||||
*/
|
||||
@Consumes(ContentType.XML)
|
||||
public abstract class XmlHandler<B> extends JacksonHandler<B> {
|
||||
|
||||
@Inject private Xml xml;
|
||||
|
||||
@Override protected final Codec codec() {
|
||||
return xml;
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package dev.relism.flash.ext.jackson.xml;
|
||||
|
||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.Http1HeaderMap;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestLine;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/** The same handler shape as JSON, over the same types and the same constraints. */
|
||||
class XmlHandlerTest {
|
||||
|
||||
public record Order(@NotBlank String reference) {}
|
||||
|
||||
static final class Place extends XmlHandler<Order> {
|
||||
@Override protected Object handle(Request request, Response response, Order body) {
|
||||
return body.reference();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theBodyArrivesParsedAsTheTypeTheHandlerDeclares() throws Exception {
|
||||
Place handler = new Place();
|
||||
handler.bind(context());
|
||||
|
||||
Object answer = handler.handle(request("<Order><reference>A-1</reference></Order>"), response());
|
||||
|
||||
assertEquals("A-1", answer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBodyThatBreaksAConstraintNeverReachesTheHandler() throws Exception {
|
||||
Place handler = new Place();
|
||||
handler.bind(context());
|
||||
|
||||
HttpException refused = assertThrows(HttpException.class,
|
||||
() -> handler.handle(request("<Order><reference></reference></Order>"), response()));
|
||||
|
||||
assertEquals(422, refused.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMalformedBodyIsABadRequest() throws Exception {
|
||||
Place handler = new Place();
|
||||
handler.bind(context());
|
||||
|
||||
HttpException refused = assertThrows(HttpException.class,
|
||||
() -> handler.handle(request("<Order><reference>"), response()));
|
||||
|
||||
assertEquals(400, refused.status());
|
||||
}
|
||||
|
||||
private static FlashContext context() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ctx.provide(Xml.class, new Xml(XmlMapper.builder().build()));
|
||||
ctx.complete();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private static Response response() {
|
||||
return new Response(200, ContentType.XML);
|
||||
}
|
||||
|
||||
private static Request request(String body) {
|
||||
return new Request(new RequestLine(HttpMethod.POST,
|
||||
new FastPathViews.StringByteView("/orders"), null,
|
||||
new FastPathViews.StringByteView("HTTP/1.1"), new Http1HeaderMap()),
|
||||
body.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
# flash-ext-jackson
|
||||
|
||||
Jackson JSON integration for Flash with an opinionated auto-marshal middleware.
|
||||
|
||||
## What it provides
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| `JacksonExtension` | Registers JSON services into `FlashContext` |
|
||||
| `Json` | JSON read/write helper (`body`, `bodyFrom`, `write`, `writeView`) |
|
||||
| `ObjectMapper` | Raw mapper escape hatch for advanced usage |
|
||||
| `JacksonMiddleware` | `autoJson()` middleware for automatic outbound JSON marshalling |
|
||||
|
||||
Default mapper behavior (`new JacksonExtension()`):
|
||||
|
||||
- auto-discovers Jackson modules on classpath (`findAndAddModules()`)
|
||||
- includes Java Time support (`jackson-datatype-jsr310`)
|
||||
- writes date/time values as ISO-8601 strings (not numeric timestamps)
|
||||
|
||||
## Recommended default
|
||||
|
||||
Install the extension, then apply `autoJson()` once at app or scope level.
|
||||
|
||||
```java
|
||||
JacksonExtension jackson = new JacksonExtension();
|
||||
|
||||
FlashApp app = FlashApp.create(8080)
|
||||
.install(jackson)
|
||||
.use(jackson.autoJson());
|
||||
|
||||
app.startAndBlock();
|
||||
```
|
||||
|
||||
Behavior of `autoJson()`:
|
||||
|
||||
- pass-through: `null`, `Response`, `byte[]`, `String`, `CharSequence`
|
||||
- any other return value: serialize to JSON `byte[]`
|
||||
- sets `Content-Type: application/json` for marshalled responses
|
||||
- serialization failures throw `IllegalStateException`
|
||||
|
||||
This keeps handlers concise while preserving Flash's direct byte write path.
|
||||
|
||||
## Installation
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
<version>1.1-indev2</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Json helper API
|
||||
|
||||
Use `Json` when you want explicit, local control in a handler.
|
||||
|
||||
```java
|
||||
@POST("/users")
|
||||
public final class CreateUser extends RequestHandler {
|
||||
private Json json;
|
||||
|
||||
@Override
|
||||
protected void onInit() {
|
||||
json = require(Json.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object handle(Request req, Response res) throws Exception {
|
||||
CreateUserBody body = json.body(req, CreateUserBody.class);
|
||||
UserDto created = service.create(body);
|
||||
res.status(201);
|
||||
return json.write(res, created);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Methods:
|
||||
|
||||
- `body(req, Type.class)` -> parse from `req.body().bytes()`
|
||||
- `bodyFrom(req, Type.class)` -> parse from `req.body().stream()`
|
||||
- `write(res, obj)` -> writes JSON string and sets JSON content type
|
||||
- `writeView(res, obj, View.class)` -> JSON with Jackson `@JsonView`
|
||||
- `mapper()` -> raw `ObjectMapper`
|
||||
|
||||
## Custom mapper
|
||||
|
||||
```java
|
||||
ObjectMapper mapper = JsonMapper.builder()
|
||||
.addModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build();
|
||||
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension(mapper));
|
||||
```
|
||||
|
||||
## Scope usage
|
||||
|
||||
`autoJson()` works the same at scope level:
|
||||
|
||||
```java
|
||||
JacksonExtension jackson = new JacksonExtension();
|
||||
|
||||
app.mount("/api", api -> {
|
||||
api.use(jackson.autoJson());
|
||||
api.get("/health", (req, res) -> Map.of("ok", true));
|
||||
});
|
||||
```
|
||||
|
||||
If you need to pull it from context, `JacksonMiddleware` is also provided as a service
|
||||
after the app boots (same lifecycle model as other extension-provided services).
|
||||
|
||||
## Notes
|
||||
|
||||
- Install order is irrelevant (Flash two-phase extension lifecycle).
|
||||
- `autoJson()` and OpenAPI are intentionally decoupled.
|
||||
@@ -1,82 +0,0 @@
|
||||
<?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</artifactId>
|
||||
|
||||
<properties>
|
||||
<jacoco.version>0.8.12</jacoco.version>
|
||||
</properties>
|
||||
|
||||
<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>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>jacoco-prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>jacoco-report-and-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>LINE</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.80</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
/**
|
||||
* Registers JSON support into the Flash extension layer.
|
||||
*
|
||||
* <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under
|
||||
* {@code Json.class}. Any handler or extension can retrieve it via {@code ctx.require(Json.class)}
|
||||
* inside {@code onInit()} (class-based) or from a {@link FlashContext#onReady(Runnable)}
|
||||
* callback (extensions).
|
||||
*
|
||||
* <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
|
||||
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
|
||||
*
|
||||
* <p>{@link JacksonMiddleware} is provided under {@code JacksonMiddleware.class} and
|
||||
* exposes opinionated JSON auto-marshalling middleware via {@link JacksonMiddleware#autoJson()}.
|
||||
*
|
||||
* <h3>Usage — composition (preferred)</h3>
|
||||
* <pre>{@code
|
||||
* public class MyHandler extends RequestHandler {
|
||||
* private Json json;
|
||||
*
|
||||
* @Override protected void onInit() {
|
||||
* json = require(Json.class);
|
||||
* }
|
||||
*
|
||||
* public Object handle(Request req, Response res) throws Exception {
|
||||
* MyDto dto = json.body(req, MyDto.class);
|
||||
* return json.write(res, 201, dto);
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>Custom mapper</h3>
|
||||
* <pre>{@code
|
||||
* ObjectMapper mapper = JsonMapper.builder()
|
||||
* .addModule(new JavaTimeModule())
|
||||
* .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
* .build();
|
||||
*
|
||||
* FlashApp.create(8080)
|
||||
* .install(new JacksonExtension(mapper));
|
||||
* }</pre>
|
||||
*/
|
||||
public class JacksonExtension implements FlashExtension {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final JacksonMiddleware middleware;
|
||||
|
||||
/**
|
||||
* Installs with an opinionated default {@link JsonMapper}:
|
||||
* auto-discovers modules on classpath (e.g. Java Time) and writes dates as ISO strings.
|
||||
*/
|
||||
public JacksonExtension() {
|
||||
this(JsonMapper.builder()
|
||||
.findAndAddModules()
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build());
|
||||
}
|
||||
|
||||
/** Installs with a fully configured custom {@link ObjectMapper}. */
|
||||
public JacksonExtension(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
this.middleware = new JacksonMiddleware(mapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opinionated outbound JSON middleware factory.
|
||||
*
|
||||
* <p>Use for app/scope-level registration:
|
||||
* <pre>{@code
|
||||
* JacksonExtension jackson = new JacksonExtension();
|
||||
* app.install(jackson).use(jackson.autoJson());
|
||||
* }</pre>
|
||||
*/
|
||||
public Middleware autoJson() {
|
||||
return middleware.autoJson();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
Json json = new Json(mapper);
|
||||
ctx.provide(Json.class, json);
|
||||
ctx.provide(ObjectMapper.class, mapper);
|
||||
ctx.provide(JacksonMiddleware.class, middleware);
|
||||
}
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* Outbound JSON marshalling middleware for class-based and lambda routes.
|
||||
*
|
||||
* <p>{@link #autoJson()} marshals any non-body-native return value to JSON bytes,
|
||||
* writes {@code Content-Type: application/json}, and returns {@code byte[]} so
|
||||
* the Flash write path stays direct.
|
||||
*
|
||||
* <p>Pass-through return types:
|
||||
* <ul>
|
||||
* <li>{@code null}</li>
|
||||
* <li>{@link Response}</li>
|
||||
* <li>{@code byte[]}</li>
|
||||
* <li>{@link String}</li>
|
||||
* <li>{@link CharSequence}</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class JacksonMiddleware {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
JacksonMiddleware(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatic JSON marshalling policy.
|
||||
*
|
||||
* <p>For non-pass-through return values, serializes with Jackson directly to
|
||||
* {@code byte[]} and sets response content type to JSON.
|
||||
*
|
||||
* @throws IllegalStateException when serialization fails
|
||||
*/
|
||||
public Middleware autoJson() {
|
||||
return next -> (req, res) -> {
|
||||
Object out = next.handle(req, res);
|
||||
if (isPassThrough(out)) return out;
|
||||
|
||||
res.type(ContentType.JSON);
|
||||
try {
|
||||
return mapper.writeValueAsBytes(out);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException(
|
||||
"Failed to serialize handler result as JSON: " + out.getClass().getName(), e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean isPassThrough(Object out) {
|
||||
return out == null
|
||||
|| out instanceof Response
|
||||
|| out instanceof byte[]
|
||||
|| out instanceof CharSequence;
|
||||
}
|
||||
}
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* Thread-safe JSON toolbox. Single point of access for all JSON I/O operations
|
||||
* within a Flash application.
|
||||
*
|
||||
* <p>Retrieve once at boot time via {@code require(Json.class)} inside
|
||||
* {@code onInit()}, cache in a private field, and call on the hot path
|
||||
* with zero lookup or allocation overhead:
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Route(method = HttpMethod.POST, path = "/api/items")
|
||||
* public class CreateItemHandler extends RequestHandler {
|
||||
*
|
||||
* private Json json;
|
||||
*
|
||||
* @Override
|
||||
* protected void onInit() {
|
||||
* json = require(Json.class);
|
||||
* }
|
||||
*
|
||||
* public Object handle(Request req, Response res) throws Exception {
|
||||
* CreateItemRequest body = json.body(req, CreateItemRequest.class);
|
||||
* return json.write(res, itemService.create(body));
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The underlying {@link ObjectMapper} is shared across all handlers in the same
|
||||
* scope (one instance per app / per child scope). Jackson's {@code ObjectMapper}
|
||||
* is fully thread-safe after configuration — no synchronization is needed.
|
||||
*
|
||||
* <p>Install via {@link JacksonExtension} before calling {@code scan()} or
|
||||
* {@code register()}.
|
||||
*/
|
||||
public final class Json {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
/** Package-private — constructed exclusively by {@link JacksonExtension}. */
|
||||
Json(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
// ── Input ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deserializes the full request body into an instance of {@code type}.
|
||||
*
|
||||
* <p>Reads {@code req.body().bytes()} in one shot. For streaming bodies
|
||||
* use {@link #bodyFrom(Request, Class)} instead.
|
||||
*
|
||||
* @throws HttpException 400 if the body cannot be parsed as {@code type}
|
||||
*/
|
||||
public <T> T body(Request req, Class<T> type) throws Exception {
|
||||
try {
|
||||
return mapper.readValue(req.body().bytes(), type);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes the request body via the raw {@link java.io.InputStream},
|
||||
* avoiding the intermediate {@code byte[]} allocation. Prefer this for
|
||||
* large bodies or when allocation budget is tight.
|
||||
*
|
||||
* @throws HttpException 400 on parse failure
|
||||
*/
|
||||
public <T> T bodyFrom(Request req, Class<T> type) throws Exception {
|
||||
try {
|
||||
return mapper.readValue(req.body().stream(), type);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serializes {@code obj} to a JSON string and sets
|
||||
* {@code Content-Type: application/json} on the response.
|
||||
*
|
||||
* <p>The returned string is used as the response body by the Flash runtime.
|
||||
*/
|
||||
public String write(Response res, Object obj) throws Exception {
|
||||
res.type(ContentType.JSON);
|
||||
return mapper.writeValueAsString(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #write} but applies a Jackson {@code @JsonView} filter,
|
||||
* restricting serialization to fields visible under {@code view}.
|
||||
*/
|
||||
public String writeView(Response res, Object obj, Class<?> view) throws Exception {
|
||||
res.type(ContentType.JSON);
|
||||
return mapper.writerWithView(view).writeValueAsString(obj);
|
||||
}
|
||||
|
||||
// ── Escape hatch ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the underlying {@link ObjectMapper} for advanced operations
|
||||
* (custom serialization, schema generation, etc.) not covered by the
|
||||
* methods above.
|
||||
*/
|
||||
public ObjectMapper mapper() {
|
||||
return mapper;
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
# flash-ext-validation
|
||||
|
||||
Request validation for Flash. Standard `jakarta.validation` annotations, compiled once per type
|
||||
into a flat check table, with zero allocation on the passing path.
|
||||
|
||||
## What it provides
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| `Validation` | The service — `body(req, type)` parses and verifies, `validate(value)` verifies |
|
||||
| `Validator` | One type's compiled constraints; reusable and thread-safe |
|
||||
| `ValidationException` | 422 carrying every violation, not just the first |
|
||||
|
||||
## Dependency
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-validation</artifactId>
|
||||
<version>${flash.version}</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```java
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension())
|
||||
.install(new ValidationExtension())
|
||||
.scan("dev.example.api");
|
||||
```
|
||||
|
||||
```java
|
||||
public record CreateUser(
|
||||
@NotBlank @Size(max = 80) String name,
|
||||
@Email String email,
|
||||
@Min(18) int age) {}
|
||||
```
|
||||
|
||||
```java
|
||||
@POST("/api/users")
|
||||
public final class CreateUserHandler extends RequestHandler {
|
||||
|
||||
private Validation validation;
|
||||
private UserService users;
|
||||
|
||||
@Override protected void onInit() {
|
||||
validation = require(Validation.class);
|
||||
users = require(UserService.class);
|
||||
}
|
||||
|
||||
@Override public Object handle(Request req, Response res) throws Exception {
|
||||
CreateUser dto = validation.body(req, CreateUser.class);
|
||||
return res.status(201).body(users.create(dto));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
There is nothing to configure. Constraints come from the annotations already on your types, and
|
||||
failures reach the client as `422` on their own — see [Error responses](#error-responses).
|
||||
|
||||
## Supported constraints
|
||||
|
||||
`@NotNull` · `@NotBlank` · `@NotEmpty` · `@Size` · `@Min` · `@Max` · `@Email` · `@Pattern`
|
||||
|
||||
Jakarta null semantics are honoured exactly: **only `@NotNull` rejects null**. Every other
|
||||
constraint passes a null value, so `@Email String email` means "if present, must look like an
|
||||
email" — combine with `@NotNull` when it is mandatory.
|
||||
|
||||
`@Size` applies to `CharSequence`, `Collection`, `Map` and object arrays. `@Min`/`@Max` apply to
|
||||
primitive integrals and to `Number` subtypes.
|
||||
|
||||
An unsupported annotation is ignored rather than rejected, so adding one is never a boot failure.
|
||||
|
||||
## Records and classes
|
||||
|
||||
Constraints are read from **declared fields**. A constraint on a record component propagates to
|
||||
its backing field, so records and plain classes take the same path with no extra configuration:
|
||||
|
||||
```java
|
||||
record CreateUser(@NotBlank String name) {} // works
|
||||
class CreateUser { @NotBlank private String name; } // works
|
||||
```
|
||||
|
||||
## Error responses
|
||||
|
||||
`ValidationException` extends Flash's `HttpException` with status 422, so the default exception
|
||||
handler renders it. Nothing is registered, and your own `onException` still wins if you set one.
|
||||
|
||||
```json
|
||||
{"error":"name must not be blank; age must be at least 18","status":422}
|
||||
```
|
||||
|
||||
Malformed JSON is a different failure and comes back as `400` from the codec, before any
|
||||
constraint runs.
|
||||
|
||||
## OpenAPI
|
||||
|
||||
Install `flash-ext-openapi` alongside and the generated schema mirrors the same annotations —
|
||||
`minLength`, `maxLength`, `minItems`, `minimum`, `maximum`, `pattern`, `format: email`, and
|
||||
`required`. Declared once, enforced and published.
|
||||
|
||||
Nothing registers this. `flash-ext-openapi` carries `jakarta.validation-api` as an optional
|
||||
dependency and detects it at boot; without it the bridge class is never loaded.
|
||||
|
||||
An explicit `@Schema` always wins — the bridge only fills keys nobody set.
|
||||
|
||||
## Without Jackson
|
||||
|
||||
`flash-ext-jackson` is optional. Without it `validate(value)` still works on values you construct
|
||||
or parse yourself; only `body(req, type)` needs a codec and says so if one is missing.
|
||||
|
||||
## Performance
|
||||
|
||||
The passing path is the one that runs on every request, so it allocates nothing:
|
||||
|
||||
- **Compiled once per type.** Constraints resolve to an opcode plus operands at first use, cached
|
||||
in a `ClassValue` — stored beside the class by the JVM, so no map lookup, no lock, and the entry
|
||||
is collected with the class rather than pinning it.
|
||||
- **No reflection per request.** Fields are read through `MethodHandle`s adapted to an exact
|
||||
signature: `(Object)Object` for references, `(Object)long` for primitive integrals. `invokeExact`
|
||||
neither boxes nor builds the argument array that `Field.get` and `Method.invoke` allocate.
|
||||
- **No megamorphic dispatch.** Checks are a flat array walked by a `tableswitch` on an opcode, not
|
||||
a class hierarchy behind a virtual call.
|
||||
- **No copies.** `@Size` reads a length the object already knows; `@Email` scans with `indexOf`
|
||||
rather than a regex, because `Pattern.matcher` allocates a matcher and two int arrays per call.
|
||||
- **Messages pre-rendered at compile time**, so even a failure formats nothing.
|
||||
|
||||
The list, the violations and the exception exist only once something fails.
|
||||
|
||||
`@Pattern` is the deliberate exception: its regex is compiled once, but `matcher()` allocates per
|
||||
call. It is marked in the source. Prefer `@Size`/`@Email` on hot routes, or validate the shape
|
||||
structurally.
|
||||
|
||||
## Pre-warming
|
||||
|
||||
Compilation happens on a type's first request. To pay it at boot instead:
|
||||
|
||||
```java
|
||||
ctx.onReady(() -> ctx.require(Validation.class).forType(CreateUser.class));
|
||||
```
|
||||
|
||||
Worth it only for a route that must not pay first-call cost. Everything else warms itself.
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
|
||||
import dev.relism.flash.ext.jackson.Json;
|
||||
import dev.relism.flash.models.Request;
|
||||
|
||||
/**
|
||||
* The validation service. Resolve it with {@code require(Validation.class)}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* CreateUser dto = validation.body(req, CreateUser.class); // parse + verify
|
||||
* }</pre>
|
||||
*
|
||||
* <p>Constraints are compiled the first time a type is seen and cached in a {@link ClassValue},
|
||||
* which the JVM stores beside the class itself — no map lookup, no lock, and the entry is
|
||||
* collected with the class rather than pinning it. Every later request walks the compiled table.
|
||||
*/
|
||||
public final class Validation {
|
||||
|
||||
private final ClassValue<Validator> validators = new ClassValue<>() {
|
||||
@Override protected Validator computeValue(Class<?> type) {
|
||||
return Validator.compile(type);
|
||||
}
|
||||
};
|
||||
|
||||
/** Null when flash-ext-jackson is absent; only {@link #body} needs it. */
|
||||
private Json json;
|
||||
|
||||
Validation() {}
|
||||
|
||||
/** Called once at boot by {@link ValidationExtension}, after the service graph resolves. */
|
||||
void bindCodec(Json json) {
|
||||
this.json = json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes the request body into {@code type} and verifies its constraints.
|
||||
*
|
||||
* @throws dev.relism.flash.exceptions.HttpException 400 if the body is not valid JSON
|
||||
* @throws ValidationException 422 if it parses but violates a constraint
|
||||
*/
|
||||
public <T> T body(Request request, Class<T> type) throws Exception {
|
||||
if (json == null)
|
||||
throw new IllegalStateException(
|
||||
"Validation.body(...) needs a JSON codec — install JacksonExtension, "
|
||||
+ "or parse yourself and call validate(...)");
|
||||
T value = json.body(request, type);
|
||||
validators.get(type).verify(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an already-constructed value.
|
||||
*
|
||||
* @return {@code value}, so it can be used inline
|
||||
* @throws ValidationException 422 on the first type's worth of failures
|
||||
*/
|
||||
public <T> T validate(T value) {
|
||||
validators.get(value.getClass()).verify(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The compiled constraints of {@code type}. Useful to pre-warm a hot DTO at boot, or to
|
||||
* check whether a type declares constraints at all.
|
||||
*/
|
||||
public Validator forType(Class<?> type) {
|
||||
return validators.get(type);
|
||||
}
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
|
||||
import dev.relism.flash.ext.jackson.Json;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
|
||||
/**
|
||||
* Installs request validation.
|
||||
*
|
||||
* <pre>{@code
|
||||
* FlashApp.create(8080)
|
||||
* .install(new JacksonExtension())
|
||||
* .install(new ValidationExtension())
|
||||
* .scan("dev.example.api");
|
||||
* }</pre>
|
||||
*
|
||||
* <p>No configuration. There is nothing to tune: constraints come from the annotations already on
|
||||
* your types, failures come back as 422 through Flash's default exception handler because
|
||||
* {@link ValidationException} carries its own status, and the JSON codec is picked up if
|
||||
* {@code flash-ext-jackson} is installed.
|
||||
*
|
||||
* <p>Install order does not matter — Flash resolves the whole service graph before any handler
|
||||
* initialises.
|
||||
*/
|
||||
public final class ValidationExtension implements FlashExtension {
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.supply(Validation.class, Validation::new);
|
||||
|
||||
// Resolved here rather than declared as a dependency: jackson is optional, and a declared
|
||||
// dependency would make it mandatory. By the time ready callbacks run the graph is
|
||||
// complete, so find() sees whatever was actually installed.
|
||||
ctx.onReady(() -> ctx.require(Validation.class).bindCodec(ctx.find(Json.class).orElse(null)));
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,9 @@
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<modules>
|
||||
<module>flash-ext-jackson</module>
|
||||
<module>flash-ext-jackson-core</module>
|
||||
<module>flash-ext-jackson-json</module>
|
||||
<module>flash-ext-jackson-xml</module>
|
||||
<module>flash-ext-openapi</module>
|
||||
<module>flash-ext-security-core</module>
|
||||
<module>flash-ext-security-oidc</module>
|
||||
@@ -30,7 +32,6 @@
|
||||
<module>flash-ext-vite</module>
|
||||
<module>flash-ext-vite-maven-plugin</module>
|
||||
<module>flash-ext-mcp</module>
|
||||
<module>flash-ext-validation</module>
|
||||
<module>flash-ext-scheduler</module>
|
||||
<module>flash-ext-data-core</module>
|
||||
<module>flash-ext-data-jdbc</module>
|
||||
@@ -41,11 +42,6 @@
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-validation</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-scheduler</artifactId>
|
||||
@@ -85,7 +81,17 @@
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
<artifactId>flash-ext-jackson-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson-json</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson-xml</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -98,6 +104,11 @@
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.17.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||
<artifactId>jackson-dataformat-xml</artifactId>
|
||||
<version>2.17.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||
<artifactId>jackson-dataformat-yaml</artifactId>
|
||||
|
||||
@@ -59,6 +59,11 @@
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<!--
|
||||
Every module of this build, so anything composing Flash imports this POM once and
|
||||
never names a version again. An extension left out here is one a consumer has to
|
||||
pin by hand, which is how two versions of Flash end up on one classpath.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
@@ -66,12 +71,47 @@
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<artifactId>flash-ext-security-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-validation</artifactId>
|
||||
<artifactId>flash-ext-security-apikey</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-security-form</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-security-oauth-server</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-security-test</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-data-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-data-jdbc</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-data-hibernate</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -96,7 +136,17 @@
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
<artifactId>flash-ext-jackson-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson-json</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson-xml</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
||||
Reference in New Issue
Block a user