diff --git a/README.md b/README.md index a724514..89714cd 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/flash-extensions/flash-ext-jackson-core/README.md b/flash-extensions/flash-ext-jackson-core/README.md new file mode 100644 index 0000000..2ad8817 --- /dev/null +++ b/flash-extensions/flash-ext-jackson-core/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)` | 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 extends JacksonHandler { + @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. diff --git a/flash-extensions/flash-ext-jackson-core/pom.xml b/flash-extensions/flash-ext-jackson-core/pom.xml new file mode 100644 index 0000000..199b79b --- /dev/null +++ b/flash-extensions/flash-ext-jackson-core/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-jackson-core + flash-ext-jackson-core + What every Jackson data format shares: the codec, the body handler and the constraints a body is checked against. + + + + dev.relism + flash + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + jakarta.validation + jakarta.validation-api + + + + org.projectlombok + lombok + provided + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java b/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Check.java similarity index 98% rename from flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java rename to flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Check.java index eda228f..f47e0cf 100644 --- a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java +++ b/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Check.java @@ -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; diff --git a/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Codec.java b/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Codec.java new file mode 100644 index 0000000..aeeeaad --- /dev/null +++ b/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Codec.java @@ -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. + * + *

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. + * + *

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. + * + *

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 body(Request request, Class 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; + } +} diff --git a/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/JacksonHandler.java b/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/JacksonHandler.java new file mode 100644 index 0000000..14e59c5 --- /dev/null +++ b/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/JacksonHandler.java @@ -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. + * + *

Format modules extend this and name their codec — {@code JsonHandler}, {@code XmlHandler}. + * An application extends those, never this one. + * + * @param the body type, which is also what the published OpenAPI document describes + */ +public abstract class JacksonHandler extends BodyHandler { + + private final Class 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"); + } + } + + /** 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); + } +} diff --git a/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Marshalling.java b/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Marshalling.java new file mode 100644 index 0000000..b0a6b6c --- /dev/null +++ b/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Marshalling.java @@ -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. + * + *

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); + } + }; + } +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java b/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/ValidationException.java similarity index 92% rename from flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java rename to flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/ValidationException.java index dac202e..ea9730c 100644 --- a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java +++ b/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/ValidationException.java @@ -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 violations; - ValidationException(List violations) { + public ValidationException(List violations) { super(422, describe(violations)); this.violations = List.copyOf(violations); } diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java b/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Validator.java similarity index 91% rename from flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java rename to flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Validator.java index 153a639..889ca8f 100644 --- a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java +++ b/flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Validator.java @@ -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 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 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; diff --git a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java b/flash-extensions/flash-ext-jackson-core/src/test/java/dev/relism/flash/ext/jackson/ValidatorTest.java similarity index 99% rename from flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java rename to flash-extensions/flash-ext-jackson-core/src/test/java/dev/relism/flash/ext/jackson/ValidatorTest.java index bd8e1d2..736690f 100644 --- a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java +++ b/flash-extensions/flash-ext-jackson-core/src/test/java/dev/relism/flash/ext/jackson/ValidatorTest.java @@ -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; diff --git a/flash-extensions/flash-ext-jackson-json/README.md b/flash-extensions/flash-ext-jackson-json/README.md new file mode 100644 index 0000000..a1833b5 --- /dev/null +++ b/flash-extensions/flash-ext-jackson-json/README.md @@ -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 { + @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. diff --git a/flash-extensions/flash-ext-jackson-json/pom.xml b/flash-extensions/flash-ext-jackson-json/pom.xml new file mode 100644 index 0000000..0185e74 --- /dev/null +++ b/flash-extensions/flash-ext-jackson-json/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-jackson-json + flash-ext-jackson-json + JSON bodies and responses: the Json codec, JsonHandler and the marshalling middleware. + + + + dev.relism + flash-ext-jackson-core + + + + org.junit.jupiter + junit-jupiter + test + + + dev.relism + flash-testing + test + + + + dev.relism + flash-ext-openapi + test + + + diff --git a/flash-extensions/flash-ext-jackson-json/src/main/java/dev/relism/flash/ext/jackson/json/Json.java b/flash-extensions/flash-ext-jackson-json/src/main/java/dev/relism/flash/ext/jackson/json/Json.java new file mode 100644 index 0000000..bc5fc8a --- /dev/null +++ b/flash-extensions/flash-ext-jackson-json/src/main/java/dev/relism/flash/ext/jackson/json/Json.java @@ -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. + * + *

{@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));
+ *     }
+ * }
+ * }
+ * + *

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); + } +} diff --git a/flash-extensions/flash-ext-jackson-json/src/main/java/dev/relism/flash/ext/jackson/json/JsonExtension.java b/flash-extensions/flash-ext-jackson-json/src/main/java/dev/relism/flash/ext/jackson/json/JsonExtension.java new file mode 100644 index 0000000..53a9851 --- /dev/null +++ b/flash-extensions/flash-ext-jackson-json/src/main/java/dev/relism/flash/ext/jackson/json/JsonExtension.java @@ -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. + * + *

{@code
+ * JsonExtension json = new JsonExtension();
+ * app.install(json).use(json.auto());
+ * }
+ * + *

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); + } +} diff --git a/flash-extensions/flash-ext-jackson-json/src/main/java/dev/relism/flash/ext/jackson/json/JsonHandler.java b/flash-extensions/flash-ext-jackson-json/src/main/java/dev/relism/flash/ext/jackson/json/JsonHandler.java new file mode 100644 index 0000000..eb1d1d0 --- /dev/null +++ b/flash-extensions/flash-ext-jackson-json/src/main/java/dev/relism/flash/ext/jackson/json/JsonHandler.java @@ -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. + * + *

{@code
+ * @POST("/users")
+ * public final class CreateUser extends JsonHandler {
+ *     @Inject private UserService users;
+ *
+ *     @Override protected Object handle(Request req, Response res, NewUser body) {
+ *         return users.create(body);
+ *     }
+ * }
+ * }
+ * + *

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 extends JacksonHandler { + + @Inject private Json json; + + @Override protected final Codec codec() { + return json; + } +} diff --git a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java b/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/ConstraintsInTheDocumentTest.java similarity index 85% rename from flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java rename to flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/ConstraintsInTheDocumentTest.java index 11868e6..dbc746b 100644 --- a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java +++ b/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/ConstraintsInTheDocumentTest.java @@ -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 diff --git a/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonExtensionTest.java b/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/JsonExtensionTest.java similarity index 83% rename from flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonExtensionTest.java rename to flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/JsonExtensionTest.java index aced4b8..098b07e 100644 --- a/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonExtensionTest.java +++ b/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/JsonExtensionTest.java @@ -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 { diff --git a/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/JsonHandlerTest.java b/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/JsonHandlerTest.java new file mode 100644 index 0000000..965db25 --- /dev/null +++ b/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/JsonHandlerTest.java @@ -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 { + @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"), 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)); + } +} diff --git a/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JsonTest.java b/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/JsonTest.java similarity index 90% rename from flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JsonTest.java rename to flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/JsonTest.java index 778d96a..16edf4a 100644 --- a/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JsonTest.java +++ b/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/JsonTest.java @@ -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()); } diff --git a/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonMiddlewareTest.java b/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/MarshallingTest.java similarity index 77% rename from flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonMiddlewareTest.java rename to flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/MarshallingTest.java index 4a7eb18..7825956 100644 --- a/flash-extensions/flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonMiddlewareTest.java +++ b/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/MarshallingTest.java @@ -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 { diff --git a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java b/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/ValidatedBodyTest.java similarity index 85% rename from flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java rename to flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/ValidatedBodyTest.java index f32b5e2..8b97a13 100644 --- a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java +++ b/flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/ValidatedBodyTest.java @@ -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())); }); }); diff --git a/flash-extensions/flash-ext-jackson-xml/README.md b/flash-extensions/flash-ext-jackson-xml/README.md new file mode 100644 index 0000000..fbcfea7 --- /dev/null +++ b/flash-extensions/flash-ext-jackson-xml/README.md @@ -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 { + @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. diff --git a/flash-extensions/flash-ext-validation/pom.xml b/flash-extensions/flash-ext-jackson-xml/pom.xml similarity index 57% rename from flash-extensions/flash-ext-validation/pom.xml rename to flash-extensions/flash-ext-jackson-xml/pom.xml index c7fa868..370e1e5 100644 --- a/flash-extensions/flash-ext-validation/pom.xml +++ b/flash-extensions/flash-ext-jackson-xml/pom.xml @@ -10,42 +10,34 @@ 2.1.0-SNAPSHOT - flash-ext-validation + flash-ext-jackson-xml + flash-ext-jackson-xml + XML bodies and responses, over the same types and the same constraints as every other Jackson format. dev.relism - flash + flash-ext-jackson-core - - jakarta.validation - jakarta.validation-api - - - - dev.relism - flash-ext-jackson - true + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + org.junit.jupiter junit-jupiter + test + + + jakarta.validation + jakarta.validation-api + test dev.relism flash-testing test - - - dev.relism - flash-ext-openapi - test - diff --git a/flash-extensions/flash-ext-jackson-xml/src/main/java/dev/relism/flash/ext/jackson/xml/Xml.java b/flash-extensions/flash-ext-jackson-xml/src/main/java/dev/relism/flash/ext/jackson/xml/Xml.java new file mode 100644 index 0000000..08bfe48 --- /dev/null +++ b/flash-extensions/flash-ext-jackson-xml/src/main/java/dev/relism/flash/ext/jackson/xml/Xml.java @@ -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. + * + *

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); + } +} diff --git a/flash-extensions/flash-ext-jackson-xml/src/main/java/dev/relism/flash/ext/jackson/xml/XmlExtension.java b/flash-extensions/flash-ext-jackson-xml/src/main/java/dev/relism/flash/ext/jackson/xml/XmlExtension.java new file mode 100644 index 0000000..4081cf6 --- /dev/null +++ b/flash-extensions/flash-ext-jackson-xml/src/main/java/dev/relism/flash/ext/jackson/xml/XmlExtension.java @@ -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. + * + *

{@code
+ * XmlExtension xml = new XmlExtension();
+ * app.install(xml).use(xml.auto());
+ * }
+ * + *

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)); + } +} diff --git a/flash-extensions/flash-ext-jackson-xml/src/main/java/dev/relism/flash/ext/jackson/xml/XmlHandler.java b/flash-extensions/flash-ext-jackson-xml/src/main/java/dev/relism/flash/ext/jackson/xml/XmlHandler.java new file mode 100644 index 0000000..80fea7a --- /dev/null +++ b/flash-extensions/flash-ext-jackson-xml/src/main/java/dev/relism/flash/ext/jackson/xml/XmlHandler.java @@ -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. + * + *

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 extends JacksonHandler { + + @Inject private Xml xml; + + @Override protected final Codec codec() { + return xml; + } +} diff --git a/flash-extensions/flash-ext-jackson-xml/src/test/java/dev/relism/flash/ext/jackson/xml/XmlHandlerTest.java b/flash-extensions/flash-ext-jackson-xml/src/test/java/dev/relism/flash/ext/jackson/xml/XmlHandlerTest.java new file mode 100644 index 0000000..452b7b8 --- /dev/null +++ b/flash-extensions/flash-ext-jackson-xml/src/test/java/dev/relism/flash/ext/jackson/xml/XmlHandlerTest.java @@ -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 { + @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("A-1"), 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(""), 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(""), 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)); + } +} diff --git a/flash-extensions/flash-ext-jackson/README.md b/flash-extensions/flash-ext-jackson/README.md deleted file mode 100644 index 7fb49df..0000000 --- a/flash-extensions/flash-ext-jackson/README.md +++ /dev/null @@ -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 - - dev.relism - flash-ext-jackson - 1.1-indev2 - -``` - -## 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. diff --git a/flash-extensions/flash-ext-jackson/pom.xml b/flash-extensions/flash-ext-jackson/pom.xml deleted file mode 100644 index 00478a5..0000000 --- a/flash-extensions/flash-ext-jackson/pom.xml +++ /dev/null @@ -1,82 +0,0 @@ - - - 4.0.0 - - - dev.relism - flash-extensions - 2.1.0-SNAPSHOT - - - flash-ext-jackson - - - 0.8.12 - - - - - dev.relism - flash - - - com.fasterxml.jackson.core - jackson-databind - - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 - - - org.projectlombok - lombok - - - org.junit.jupiter - junit-jupiter - - - - - - - org.jacoco - jacoco-maven-plugin - ${jacoco.version} - - - jacoco-prepare-agent - - prepare-agent - - - - jacoco-report-and-check - verify - - report - check - - - - - BUNDLE - - - LINE - COVEREDRATIO - 0.80 - - - - - - - - - - - - diff --git a/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonExtension.java b/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonExtension.java deleted file mode 100644 index dc9a4c9..0000000 --- a/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonExtension.java +++ /dev/null @@ -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. - * - *

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). - * - *

The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class} - * for extensions that need direct mapper access (e.g. OpenAPI schema generation). - * - *

{@link JacksonMiddleware} is provided under {@code JacksonMiddleware.class} and - * exposes opinionated JSON auto-marshalling middleware via {@link JacksonMiddleware#autoJson()}. - * - *

Usage — composition (preferred)

- *
{@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);
- *     }
- * }
- * }
- * - *

Custom mapper

- *
{@code
- * ObjectMapper mapper = JsonMapper.builder()
- *         .addModule(new JavaTimeModule())
- *         .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
- *         .build();
- *
- * FlashApp.create(8080)
- *         .install(new JacksonExtension(mapper));
- * }
- */ -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. - * - *

Use for app/scope-level registration: - *

{@code
-     * JacksonExtension jackson = new JacksonExtension();
-     * app.install(jackson).use(jackson.autoJson());
-     * }
- */ - 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); - } -} diff --git a/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonMiddleware.java b/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonMiddleware.java deleted file mode 100644 index 3970158..0000000 --- a/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonMiddleware.java +++ /dev/null @@ -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. - * - *

{@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. - * - *

Pass-through return types: - *

    - *
  • {@code null}
  • - *
  • {@link Response}
  • - *
  • {@code byte[]}
  • - *
  • {@link String}
  • - *
  • {@link CharSequence}
  • - *
- */ -public final class JacksonMiddleware { - - private final ObjectMapper mapper; - - JacksonMiddleware(ObjectMapper mapper) { - this.mapper = mapper; - } - - /** - * Automatic JSON marshalling policy. - * - *

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; - } -} diff --git a/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/Json.java b/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/Json.java deleted file mode 100644 index 3b3d0f5..0000000 --- a/flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/Json.java +++ /dev/null @@ -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. - * - *

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: - * - *

{@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));
- *     }
- * }
- * }
- * - *

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. - * - *

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}. - * - *

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 body(Request req, Class 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 bodyFrom(Request req, Class 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. - * - *

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; - } -} diff --git a/flash-extensions/flash-ext-openapi/README.md b/flash-extensions/flash-ext-openapi/README.md index 4d0c868..1a1e797 100644 --- a/flash-extensions/flash-ext-openapi/README.md +++ b/flash-extensions/flash-ext-openapi/README.md @@ -1,6 +1,7 @@ # flash-ext-openapi -OpenAPI 3.0.3 generation + Swagger UI for Flash. +OpenAPI 3.0.3 generation and Swagger UI, built from what the handlers already say about +themselves. ## What it provides @@ -10,8 +11,6 @@ OpenAPI 3.0.3 generation + Swagger UI for Flash. | `GET /openapi.yaml` | OpenAPI spec YAML | | `GET /openapi/swagger` | Swagger UI | -## Install - ```java FlashApp.create(8080) .install(new JacksonExtension()) @@ -20,131 +19,122 @@ FlashApp.create(8080) .startAndBlock(); ``` -## Operation annotation +## What you get without writing anything + +Every class-based route is documented, annotated or not. Read off the code: + +- **path parameters**, from `/{id}` in the route +- **the request body**, from the handler's own body type (see below) +- **the response schema**, from what `handle` returns — an object, a `List`, a `Map` +- **error responses**, in the one shape Flash answers failures with: `{"error": "...", "status": 404}` +- **security and rate limiting**, from the extensions that enforce them + +Annotations add what the code cannot say: prose, extra statuses, examples. They never repeat it. + +## Request bodies + +A handler that extends `BodyHandler` — `JsonHandler` and `XmlHandler`, and anything else that +reads a format — declares its body type in its signature, and that is the whole documentation: + +```java +@POST("/users") +public final class CreateUser extends JsonHandler { + @Override protected Object handle(Request req, Response res, NewUser body) { + return users.create(body); + } +} +``` + +```yaml +requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/NewUser' } +``` + +The media type comes from `@Consumes` on the base class, so an XML handler documents itself as +XML without a word from the route. + +For a handler that reads the body by hand, or to describe it as something else, declare it: + +```java +@PUT("/users") +@RequestBody(value = User.class, array = true, description = "Users to store") +public final class ReplaceUsers extends RequestHandler { ... } +``` + +## Operations ```java @GET("/users/{id}") @ApiOperation(summary = "Get user", description = "Returns one user", tags = {"users"}) @Parameter(name = "expand", in = ParameterIn.QUERY, type = SchemaType.STRING, examples = {"roles", "permissions"}) -@APIResponse( - responseCode = "200", - description = "User found", - content = @Content(contentType = ContentType.JSON, schema = UserDto.class) -) public final class GetUser extends RequestHandler { ... } ``` -## Response patterns +`@ApiOperation` is optional: without it the route is still in the document, with no summary. -### Single object +## Responses + +The success response is inferred. Declare one only to say more: ```java -@APIResponse( - responseCode = "200", - description = "User found", - content = @Content(contentType = ContentType.JSON, schema = UserDto.class) -) +@APIResponse(responseCode = "200", description = "User found", + content = @Content(schema = UserDto.class, example = "{\"id\":\"usr-1\"}")) +@APIResponse(responseCode = "409", description = "That email is taken") +@APIResponse(responseCode = "204", content = @Content(contentType = ContentType.NONE)) ``` -### Array +- `content.schema` omitted on a 2xx: the handler's return type. +- Any 4xx or 5xx without an explicit schema: Flash's error object, referenced from `components`. +- `content.array = true` wraps whichever schema was chosen. +- `contentType = NONE` documents a response with no body. + +**A response several operations share is written once.** Identical answers — the 401 of every +guarded route, the 429 of every limited one — become `components.responses` entries referenced by +`$ref`, instead of being repeated on every path. + +## DTO schemas ```java -@APIResponse( - responseCode = "200", - description = "Users listed", - content = @Content(contentType = ContentType.JSON, schema = UserDto.class, array = true) -) +@Schema(name = "User", title = "User DTO", description = "Public user") +public record UserDto( + @SchemaProperty(title = "ID", example = "USR-100") String id, + @SchemaProperty(hidden = true) String internalDebug) {} ``` -### No content - -```java -@APIResponse( - responseCode = "204", - description = "Deleted", - content = @Content(contentType = ContentType.NONE) -) -``` - -### Inferred from handler return type - -```java -@APIResponse( - responseCode = "200", - content = @Content -) -``` - -If `content.schema` is omitted, schema is inferred from the handler `handle(...)` return type. -Explicit `content.schema` always wins over inference. - -Inference defaults: - -- `UserDto` -> object schema for `UserDto` -- `List` / `Set` / `UserDto[]` -> `array` with `items: UserDto` -- `Map` -> `object` with `additionalProperties: UserDto` - -## DTO schema metadata - -```java -@Schema(name = "User", title = "User DTO", description = "Public user", deprecated = false) -public class UserDto { - - @SchemaProperty(title = "ID", required = true, example = "USR-100", enumeration = {"USR-100", "USR-101"}) - public String id; - - @SchemaProperty(hidden = true) - public String internalDebug; -} -``` - -Supported field-level exclusion: - -- `@Schema(hidden = true)` / `@SchemaProperty(hidden = true)` -- `@JsonIgnore` -- `@JsonIgnoreProperties(...)` -- `transient` / `static` +Each type is described once under `components.schemas` and referenced everywhere it appears. +Field-level exclusion: `@Schema(hidden = true)`, `@SchemaProperty(hidden = true)`, `@JsonIgnore`, +`@JsonIgnoreProperties`, `transient`, `static`. `jakarta.validation` constraints (`@NotNull`, +`@NotBlank`, `@NotEmpty`, `@Size`, `@Min`, `@Max`, `@Email`, `@Pattern`) become the schema's own +bounds and required fields, so a rule is written once and documented for free. ## Contributor API -OpenAPI is extension-agnostic. Other extensions contribute with `OpenApiContributor` via -`OpenApiContributorRegistry`. +OpenAPI is extension-agnostic. Other extensions contribute through `OpenApiContributor`, held in +`OpenApiContributorRegistry`: -Supported contribution surfaces: - -- `components` fragments (merged with last-wins) +- `components` fragments (merged last-wins) - operation `security` requirements (additive) - operation `responses` and response `headers` (additive) -Merge policy: +Manual `@APIResponse` description always wins over a contributor's for the same status. -- contributor collisions use **last-wins** -- manual `@APIResponse` description always wins over contributors for the same status - -## Security interop +### Security interop With `flash-ext-security-core` installed, every registered mechanism's scheme lands under `components.securitySchemes`, and every operation carrying a security annotation lists them as `security` alternatives with automatic `401` and — for roles or scopes — `403` responses. -Manual `@APIResponse` for the same status code always wins. +### Limiter interop -## Limiter interop - -When `flash-ext-limiter` is installed, handlers with `@Limit` automatically get response -headers documented in OpenAPI: - -- `X-RateLimit-Limit` -- `X-RateLimit-Remaining` -- `X-RateLimit-Reset` -- `Retry-After` on `429` - -If `429` is missing, it is auto-added as `Too Many Requests`. +When `flash-ext-limiter` is installed, handlers with `@Limit` document `X-RateLimit-Limit`, +`X-RateLimit-Remaining`, `X-RateLimit-Reset`, and a `429` with `Retry-After`. ## Notes -- Operations are collected from final boot-time routes for class-based handlers with `@ApiOperation`. -- Documented paths always match runtime paths (including scope namespaces/prefixes/rewrites). -- Route path params are auto-discovered from `/{id}`. -- Parameter annotations are mainly for query/header/cookie enrichment. -- Output responses are sorted by numeric status code. +- Operations come from the final boot-time routes, so documented paths match runtime paths, + namespaces, prefixes and rewrites included. +- Lambda routes are not documented: there is no class to read. +- Responses are sorted by status code; the document is rebuilt only when a route is added. diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Content.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Content.java index b93bc9b..b86a38c 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Content.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Content.java @@ -16,4 +16,7 @@ public @interface Content { ContentType contentType() default ContentType.JSON; Class schema() default Void.class; boolean array() default false; + + /** One example body, shown beside the schema. */ + String example() default ""; } diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java index 63c18c4..e305878 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java @@ -1,47 +1,50 @@ package dev.relism.flash.ext.openapi; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonProperty.Access; -import dev.relism.flash.http.HttpMethod; import dev.relism.flash.http.ContentType; +import dev.relism.flash.http.HttpMethod; import dev.relism.flash.http.HttpStatus; -import dev.relism.flash.models.Request; -import dev.relism.flash.models.Response; +import dev.relism.flash.models.BodyHandler; +import dev.relism.flash.routing.Consumes; import dev.relism.flash.routing.Route; import java.lang.annotation.Annotation; -import java.lang.reflect.Array; -import java.lang.reflect.Field; -import java.lang.reflect.GenericArrayType; import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.OffsetDateTime; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; -import java.util.UUID; -import java.nio.charset.StandardCharsets; /** - * OpenAPI document assembler. + * Assembles the OpenAPI document from what the handlers already say about themselves. + * + *

A route is documented whether or not it carries annotations: its path parameters come from + * the path, its request body from the handler's own body type, its response schema from what + * {@code handle} returns, and its error bodies from the one shape Flash answers failures with. + * Annotations add what code cannot say — a summary, an example, a second status — and never have + * to repeat what it can. + * + *

A response that several operations share is written once under {@code components} and + * referenced, so the security and rate-limiting answers appear once rather than on every path. */ public final class OpenApiBuilder { private static final String OPENAPI_VERSION = "3.0.3"; + private static final String ERROR_SCHEMA = "Error"; + private static final String ERROR_REF = "#/components/schemas/" + ERROR_SCHEMA; + private static final String RESPONSE_REF = "#/components/responses/"; + + /** What {@code AbstractRouter} answers every failure with: one object, everywhere. */ + private static final Map ERROR_SHAPE = Map.of( + "type", "object", + "properties", Map.of("error", Map.of("type", "string"), "status", Map.of("type", "integer")), + "required", List.of("error", "status")); private String title = "API"; private String version = "1.0.0"; @@ -49,8 +52,9 @@ public final class OpenApiBuilder { private final Map> paths = new LinkedHashMap<>(); private final Map>> operationHandlers = new LinkedHashMap<>(); - private final SchemaRegistry schemas = new SchemaRegistry(); + private final Schemas schemas = new Schemas(); private OpenApiContributorRegistry contributorRegistry; + private boolean errorsDocumented; private int revision; private int builtRevision = -1; private Map cachedSpec; @@ -60,274 +64,374 @@ public final class OpenApiBuilder { public OpenApiBuilder description(String description) { this.description = description; return this; } void setContributorRegistry(OpenApiContributorRegistry registry) { this.contributorRegistry = registry; } + /** Documents one route. {@code op} is optional: a route without it is still an operation. */ public void addOperation(Route route, ApiOperation op, Class handlerClass) { String path = normalizePath(route.path()); String method = route.method().name().toLowerCase(Locale.ROOT); Map operation = new LinkedHashMap<>(); - if (!op.operationId().isEmpty()) operation.put("operationId", op.operationId()); - if (!op.summary().isEmpty()) operation.put("summary", op.summary()); - if (!op.description().isEmpty()) operation.put("description", op.description()); - if (op.tags().length > 0) operation.put("tags", Arrays.asList(op.tags())); - if (op.deprecated()) operation.put("deprecated", true); + if (op != null) { + if (!op.operationId().isEmpty()) operation.put("operationId", op.operationId()); + if (!op.summary().isEmpty()) operation.put("summary", op.summary()); + if (!op.description().isEmpty()) operation.put("description", op.description()); + if (op.tags().length > 0) operation.put("tags", List.of(op.tags())); + if (op.deprecated()) operation.put("deprecated", true); + } - buildParameters(operation, handlerClass, route); - buildResponses(operation, handlerClass); + parameters(operation, handlerClass, route); + requestBody(operation, handlerClass); + responses(operation, handlerClass); - paths.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, operation); - operationHandlers.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, handlerClass); + paths.computeIfAbsent(path, p -> new LinkedHashMap<>()).put(method, operation); + operationHandlers.computeIfAbsent(path, p -> new LinkedHashMap<>()).put(method, handlerClass); revision++; } public Map build() { - int r = revision; + int current = revision; Map cached = cachedSpec; - if (cached != null && builtRevision == r) return cached; + if (cached != null && builtRevision == current) return cached; + + List contributors = contributors(); + Map renderedPaths = new LinkedHashMap<>(); + for (var path : paths.entrySet()) { + Map> handlers = operationHandlers.getOrDefault(path.getKey(), Map.of()); + Map pathItem = new LinkedHashMap<>(); + for (var method : path.getValue().entrySet()) { + @SuppressWarnings("unchecked") + Map declared = (Map) method.getValue(); + Map operation = new LinkedHashMap<>(declared); + Class handler = handlers.get(method.getKey()); + if (handler != null && !contributors.isEmpty()) applyContributorSecurity(operation, handler, contributors); + pathItem.put(method.getKey(), operation); + } + renderedPaths.put(path.getKey(), pathItem); + } + + Map sharedResponses = hoistSharedResponses(renderedPaths); Map info = new LinkedHashMap<>(); info.put("title", title); info.put("version", version); if (!description.isEmpty()) info.put("description", description); - List contributors = contributorRegistry != null - ? contributorRegistry.contributors() : List.of(); - - Map renderedPaths = new LinkedHashMap<>(); - for (var pathEntry : paths.entrySet()) { - Map renderedPathItem = new LinkedHashMap<>(); - Map> handlers = operationHandlers.getOrDefault(pathEntry.getKey(), Map.of()); - for (var methodEntry : pathEntry.getValue().entrySet()) { - @SuppressWarnings("unchecked") - Map original = (Map) methodEntry.getValue(); - Map op = new LinkedHashMap<>(original); - Class handler = handlers.get(methodEntry.getKey()); - if (handler != null && !contributors.isEmpty()) { - applyContributorOperation(op, handler, contributors); - } - renderedPathItem.put(methodEntry.getKey(), op); - } - renderedPaths.put(pathEntry.getKey(), renderedPathItem); - } - Map spec = new LinkedHashMap<>(); spec.put("openapi", OPENAPI_VERSION); spec.put("info", info); spec.put("paths", renderedPaths); Map components = new LinkedHashMap<>(); - Map renderedSchemas = schemas.render(); + Map renderedSchemas = new LinkedHashMap<>(schemas.render()); + if (errorsDocumented) renderedSchemas.put(ERROR_SCHEMA, ERROR_SHAPE); if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas); - if (!contributors.isEmpty()) applyContributorComponents(components, contributors); + if (!sharedResponses.isEmpty()) components.put("responses", sharedResponses); + for (OpenApiContributor contributor : contributors) { + Map contributed = contributor.componentContributions(); + if (contributed != null && !contributed.isEmpty()) deepMergeLastWins(components, contributed); + } if (!components.isEmpty()) spec.put("components", components); cachedSpec = spec; - builtRevision = r; + builtRevision = current; return spec; } - private void buildParameters(Map op, Class cls, Route route) { - List> params = new ArrayList<>(); + // ── Parameters ──────────────────────────────────────────────────────────── + + private void parameters(Map operation, Class handlerClass, Route route) { + List> parameters = new ArrayList<>(); String path = route.path(); - int i = 0; - while (i < path.length()) { - int open = path.indexOf('{', i); - if (open < 0) break; + for (int open = path.indexOf('{'); open >= 0; open = path.indexOf('{', open + 1)) { int close = path.indexOf('}', open); if (close < 0) break; - String name = path.substring(open + 1, close); - params.add(new LinkedHashMap<>(Map.of( - "name", name, + parameters.add(new LinkedHashMap<>(Map.of( + "name", path.substring(open + 1, close), "in", "path", "required", true, - "schema", Map.of("type", "string") - ))); - i = close + 1; + "schema", Map.of("type", "string")))); + open = close; } - for (Parameter ann : cls.getAnnotationsByType(Parameter.class)) { - Map p = new LinkedHashMap<>(); - p.put("name", ann.name()); - p.put("in", ann.in().wireValue()); - p.put("required", ann.required()); - if (!ann.description().isEmpty()) p.put("description", ann.description()); - if (!ann.style().isEmpty()) p.put("style", ann.style()); - if (ann.explode()) p.put("explode", true); - if (ann.allowEmptyValue()) p.put("allowEmptyValue", true); + for (Parameter declared : handlerClass.getAnnotationsByType(Parameter.class)) { + Map parameter = new LinkedHashMap<>(); + parameter.put("name", declared.name()); + parameter.put("in", declared.in().wireValue()); + parameter.put("required", declared.required()); + if (!declared.description().isEmpty()) parameter.put("description", declared.description()); + if (!declared.style().isEmpty()) parameter.put("style", declared.style()); + if (declared.explode()) parameter.put("explode", true); + if (declared.allowEmptyValue()) parameter.put("allowEmptyValue", true); Map schema = new LinkedHashMap<>(); - schema.put("type", ann.type().wireValue()); - if (!ann.example().isEmpty()) schema.put("example", ann.example()); - p.put("schema", schema); - if (ann.examples().length > 0) p.put("examples", toExamples(ann.examples())); - params.add(p); + schema.put("type", declared.type().wireValue()); + if (!declared.example().isEmpty()) schema.put("example", declared.example()); + parameter.put("schema", schema); + if (declared.examples().length > 0) parameter.put("examples", examples(declared.examples())); + parameters.add(parameter); } - if (!params.isEmpty()) op.put("parameters", params); + if (!parameters.isEmpty()) operation.put("parameters", parameters); } - private void buildResponses(Map op, Class cls) { - APIResponse[] anns = cls.getAnnotationsByType(APIResponse.class); - Map> responseByCode = new LinkedHashMap<>(); + private static Map examples(String[] values) { + Map examples = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i++) examples.put("example" + (i + 1), Map.of("value", values[i])); + return examples; + } - for (APIResponse ann : anns) { - int code = parseStatus(ann.responseCode()); - responseByCode.put(code, buildAnnotatedResponse(code, ann, cls)); + // ── Request body ────────────────────────────────────────────────────────── + + /** From {@link RequestBody}, or from the body type the handler declares in its own signature. */ + private void requestBody(Map operation, Class handlerClass) { + RequestBody declared = handlerClass.getAnnotation(RequestBody.class); + Class type = declared != null ? declared.value() : BodyHandler.bodyTypeOf(handlerClass); + if (type == null || type == Void.class || type == Object.class) return; + + Consumes consumes = handlerClass.getAnnotation(Consumes.class); + ContentType contentType = declared != null ? declared.contentType() + : consumes != null ? consumes.value() : ContentType.JSON; + + Map schema = schemas.referenceFor(type); + if (declared != null && declared.array()) schema = arrayOf(schema); + + Map body = new LinkedHashMap<>(); + if (declared != null && !declared.description().isEmpty()) body.put("description", declared.description()); + body.put("required", declared == null || declared.required()); + body.put("content", Map.of(mediaTypeOf(contentType), Map.of("schema", schema))); + operation.put("requestBody", body); + } + + // ── Responses ───────────────────────────────────────────────────────────── + + private void responses(Map operation, Class handlerClass) { + APIResponse[] declared = handlerClass.getAnnotationsByType(APIResponse.class); + Map> byStatus = new LinkedHashMap<>(); + Set declaredStatuses = new HashSet<>(); + + for (APIResponse response : declared) { + int status = parseStatus(response.responseCode()); + declaredStatuses.add(status); + byStatus.put(status, response(status, response, handlerClass)); } + if (byStatus.isEmpty()) byStatus.put(200, inferredResponse(handlerClass)); - if (responseByCode.isEmpty()) { - // Mutable: contributors merge descriptions and headers into it. - responseByCode.put(200, new LinkedHashMap<>(Map.of("description", "OK"))); - } - - applyContributorResponses(responseByCode, cls); + applyContributorResponses(byStatus, handlerClass, declaredStatuses); Map responses = new LinkedHashMap<>(); - responseByCode.entrySet().stream() + byStatus.entrySet().stream() .sorted(Map.Entry.comparingByKey(Comparator.naturalOrder())) - .forEach(e -> responses.put(String.valueOf(e.getKey()), e.getValue())); - op.put("responses", responses); + .forEach(entry -> responses.put(String.valueOf(entry.getKey()), entry.getValue())); + operation.put("responses", responses); } + private Map response(int status, APIResponse declared, Class handlerClass) { + Map response = new LinkedHashMap<>(); + response.put("description", declared.description().isEmpty() ? reasonFor(status) : declared.description()); + + Content content = declared.content(); + if (content.contentType() == ContentType.NONE) return response; + + Map schema = schemaFor(content, status, handlerClass); + if (schema == null) return response; + + Map media = new LinkedHashMap<>(); + media.put("schema", schema); + if (!content.example().isEmpty()) media.put("example", content.example()); + response.put("content", Map.of(mediaTypeOf(content.contentType()), media)); + return response; + } + + /** Explicit first, then what the handler returns for a success, and the error shape for a failure. */ + private Map schemaFor(Content content, int status, Class handlerClass) { + if (content.schema() != Void.class) { + Map schema = schemas.referenceFor(content.schema()); + return content.array() ? arrayOf(schema) : schema; + } + if (status >= 400) return errorSchema(); + + Map inferred = returnSchema(handlerClass); + if (inferred == null) return null; + return content.array() && !"array".equals(inferred.get("type")) ? arrayOf(inferred) : inferred; + } + + /** A route that documents nothing still answers something: describe what it returns. */ + private Map inferredResponse(Class handlerClass) { + Map response = new LinkedHashMap<>(Map.of("description", reasonFor(200))); + Map schema = returnSchema(handlerClass); + if (schema != null) response.put("content", Map.of(mediaTypeOf(ContentType.JSON), Map.of("schema", schema))); + return response; + } + + /** The schema of whatever {@code handle} gives back, or null when it says nothing useful. */ + private Map returnSchema(Class handlerClass) { + Type returned = returnTypeOf(handlerClass); + Class raw = Schemas.rawType(returned); + if (raw == null || raw == Object.class || raw == Void.class || raw == void.class) return null; + if (raw.getName().equals("dev.relism.flash.models.Response")) return null; + + Map schema = schemas.schemaForType(returned); + return schema == null || schema.isEmpty() ? null : schema; + } + + /** + * The {@code handle} a handler writes itself, not the one its base class fixes: a handler that + * takes a body implements the three-argument one, and that is where its return type is. + */ + private static Type returnTypeOf(Class handlerClass) { + for (Class current = handlerClass; current != null && current != Object.class; current = current.getSuperclass()) { + Method found = null; + for (Method method : current.getDeclaredMethods()) { + if (!method.getName().equals("handle") || method.isBridge() || method.isSynthetic()) continue; + if (found == null || method.getParameterCount() > found.getParameterCount()) found = method; + } + if (found != null) return found.getGenericReturnType(); + } + return null; + } + + private Map errorSchema() { + errorsDocumented = true; + return Map.of("$ref", ERROR_REF); + } + + // ── Contributors ────────────────────────────────────────────────────────── + private List contributors() { return contributorRegistry != null ? contributorRegistry.contributors() : List.of(); } - private void applyContributorResponses(Map> responseByCode, Class handlerClass) { - APIResponse[] manual = handlerClass.getAnnotationsByType(APIResponse.class); - Set manualStatusCodes = new HashSet<>(); - for (APIResponse ann : manual) { - manualStatusCodes.add(parseStatus(ann.responseCode())); - } - + private void applyContributorResponses(Map> byStatus, Class handlerClass, + Set declaredStatuses) { for (OpenApiContributor contributor : contributors()) { OpenApiOperationContribution contribution = contributor.operationFor(handlerClass); if (contribution == null) continue; - for (Map.Entry entry : contribution.responses().entrySet()) { - int status = entry.getKey(); - OpenApiResponseContribution responseContribution = entry.getValue(); - if (responseContribution == null) continue; - - Map response = responseByCode.computeIfAbsent(status, __ -> new LinkedHashMap<>()); - mergeContributorResponse(response, responseContribution, status, manualStatusCodes); + for (var contributed : contribution.responses().entrySet()) { + if (contributed.getValue() == null) continue; + int status = contributed.getKey(); + Map response = byStatus.computeIfAbsent(status, s -> new LinkedHashMap<>()); + merge(response, contributed.getValue(), status, declaredStatuses.contains(status)); } - OpenApiResponseContribution allResponses = contribution.allResponses(); - if (allResponses != null) { - for (Map.Entry> entry : responseByCode.entrySet()) { - mergeContributorResponse(entry.getValue(), allResponses, entry.getKey(), manualStatusCodes); - } + OpenApiResponseContribution everywhere = contribution.allResponses(); + if (everywhere == null) continue; + for (var response : byStatus.entrySet()) { + merge(response.getValue(), everywhere, response.getKey(), declaredStatuses.contains(response.getKey())); } } } - private static void mergeContributorResponse(Map response, - OpenApiResponseContribution contribution, - int status, - Set manualStatusCodes) { - String desc = contribution.description(); - if (!manualStatusCodes.contains(status) && desc != null && !desc.isBlank()) { - response.put("description", desc); - } + private void merge(Map response, OpenApiResponseContribution contributed, int status, boolean declared) { + String description = contributed.description(); + if (!declared && description != null && !description.isBlank()) response.put("description", description); - Map> headerContributions = contribution.headers(); - if (!headerContributions.isEmpty()) { + Map> headers = contributed.headers(); + if (!headers.isEmpty()) { @SuppressWarnings("unchecked") - Map headers = (Map) response.computeIfAbsent("headers", __ -> new LinkedHashMap<>()); - for (Map.Entry> h : headerContributions.entrySet()) { - headers.put(h.getKey(), new LinkedHashMap<>(h.getValue())); - } + Map target = (Map) response.computeIfAbsent("headers", h -> new LinkedHashMap<>()); + headers.forEach((name, header) -> target.put(name, new LinkedHashMap<>(header))); } - - if (!response.containsKey("description")) { - response.put("description", defaultDescription(status)); + if (!response.containsKey("description")) response.put("description", reasonFor(status)); + // A status a contributor added is a failure Flash answers in its own shape. + if (status >= 400 && !response.containsKey("content")) { + response.put("content", Map.of(mediaTypeOf(ContentType.JSON), Map.of("schema", errorSchema()))); } } - private static void applyContributorComponents(Map components, List contributors) { + private void applyContributorSecurity(Map operation, Class handlerClass, + List contributors) { for (OpenApiContributor contributor : contributors) { - Map c = contributor.componentContributions(); - if (c == null || c.isEmpty()) continue; - deepMergeLastWins(components, c); + OpenApiOperationContribution contribution = contributor.operationFor(handlerClass); + if (contribution == null || contribution.security().isEmpty()) continue; + @SuppressWarnings("unchecked") + List>> security = + (List>>) operation.computeIfAbsent("security", s -> new ArrayList<>()); + security.addAll(contribution.security()); } } @SuppressWarnings("unchecked") private static void deepMergeLastWins(Map target, Map incoming) { - for (Map.Entry e : incoming.entrySet()) { - Object existing = target.get(e.getKey()); - Object value = e.getValue(); - if (existing instanceof Map em && value instanceof Map vm) { - Map merged = new LinkedHashMap<>((Map) em); - deepMergeLastWins(merged, (Map) vm); - target.put(e.getKey(), merged); + incoming.forEach((key, value) -> { + Object existing = target.get(key); + if (existing instanceof Map from && value instanceof Map to) { + Map merged = new LinkedHashMap<>((Map) from); + deepMergeLastWins(merged, (Map) to); + target.put(key, merged); } else { - target.put(e.getKey(), value); + target.put(key, value); } - } + }); } - private void applyContributorOperation(Map op, Class handlerClass, List contributors) { - for (OpenApiContributor contributor : contributors) { - OpenApiOperationContribution contribution = contributor.operationFor(handlerClass); - if (contribution == null || contribution.isEmpty()) continue; + // ── Shared responses ────────────────────────────────────────────────────── - if (!contribution.security().isEmpty()) { - @SuppressWarnings("unchecked") - List>> security = (List>>) op - .computeIfAbsent("security", __ -> new ArrayList<>()); - security.addAll(contribution.security()); + /** + * Whatever answer more than one operation gives identically is written once under + * {@code components.responses} and referenced. Authentication and rate limiting say the same + * thing on every route they guard; the document should say it once. + */ + @SuppressWarnings("unchecked") + private Map hoistSharedResponses(Map renderedPaths) { + Map seen = new HashMap<>(); + for (Object pathItem : renderedPaths.values()) { + for (Object operation : ((Map) pathItem).values()) { + Map responses = (Map) ((Map) operation).get("responses"); + responses.forEach((status, response) -> seen.merge(key(status, response), 1, Integer::sum)); } } + + Map names = new LinkedHashMap<>(); + Map shared = new LinkedHashMap<>(); + for (Object pathItem : renderedPaths.values()) { + for (Object operation : ((Map) pathItem).values()) { + Map responses = (Map) ((Map) operation).get("responses"); + for (var response : responses.entrySet()) { + Object key = key(response.getKey(), response.getValue()); + if (seen.getOrDefault(key, 0) < 2) continue; + + String name = names.get(key); + if (name == null) { + name = uniqueName(reasonFor(Integer.parseInt(response.getKey())), shared); + names.put(key, name); + shared.put(name, response.getValue()); + } + response.setValue(Map.of("$ref", RESPONSE_REF + name)); + } + } + } + return shared; } - private Map buildAnnotatedResponse(int code, APIResponse ann, Class handlerClass) { - Map out = new LinkedHashMap<>(); - out.put("description", ann.description().isEmpty() ? defaultDescription(code) : ann.description()); + private static Object key(String status, Object response) { + return status + response; + } - Content content = ann.content(); - if (content.contentType() == ContentType.NONE) return out; + private static String uniqueName(String reason, Map taken) { + String base = reason.isEmpty() ? "Response" : reason.replace(" ", ""); + String name = base; + for (int i = 2; taken.containsKey(name); i++) name = base + i; + return name; + } - Map schema = resolveResponseSchema(content, handlerClass); - if (schema == null || schema.isEmpty()) return out; + // ── Odds and ends ───────────────────────────────────────────────────────── - out.put("content", Map.of(mediaTypeOf(content.contentType()), Map.of("schema", schema))); - return out; + private static Map arrayOf(Map items) { + return Map.of("type", "array", "items", items); } private static int parseStatus(String code) { try { return Integer.parseInt(code.trim()); - } catch (Exception e) { + } catch (NumberFormatException e) { throw new IllegalStateException("Invalid APIResponse.responseCode: " + code); } } - private Map resolveResponseSchema(Content content, Class handlerClass) { - if (content.schema() != Void.class) { - Map base = schemas.referenceFor(content.schema()); - return content.array() ? asArraySchema(base) : base; - } - - try { - Method handle = handlerClass.getMethod("handle", Request.class, Response.class); - Type ret = handle.getGenericReturnType(); - Class raw = rawType(ret); - if (raw == null || raw == Object.class || raw == Response.class || raw == Void.class || raw == void.class) - return null; - - Map inferred = schemas.schemaForType(ret); - if (inferred == null || inferred.isEmpty()) return null; - if (content.array() && !"array".equals(inferred.get("type"))) return asArraySchema(inferred); - return inferred; - } catch (NoSuchMethodException e) { - return null; - } - } - - private static Map asArraySchema(Map itemSchema) { - return Map.of("type", "array", "items", itemSchema); + private static String reasonFor(int status) { + String reason = HttpStatus.reasonForCode(status); + return reason == null ? "" : reason; } private static String mediaTypeOf(ContentType type) { @@ -335,245 +439,21 @@ public final class OpenApiBuilder { return bytes.length == 0 ? "application/octet-stream" : new String(bytes, StandardCharsets.UTF_8); } - private static Map toExamples(String[] examples) { - Map out = new LinkedHashMap<>(); - for (int i = 0; i < examples.length; i++) { - out.put("ex" + (i + 1), Map.of("value", examples[i])); - } - return out; - } - private static String normalizePath(String path) { String normalized = path.startsWith("/") ? path : "/" + path; - while (normalized.startsWith("//")) { - normalized = normalized.substring(1); - } + while (normalized.startsWith("//")) normalized = normalized.substring(1); return normalized; } - private static String defaultDescription(int status) { - String reason = HttpStatus.reasonForCode(status); - return reason == null ? "" : reason; - } - - private static Class rawType(Type type) { - if (type instanceof Class c) return c; - if (type instanceof ParameterizedType p && p.getRawType() instanceof Class c) return c; - if (type instanceof GenericArrayType a) { - Class component = rawType(a.getGenericComponentType()); - return component == null ? null : Array.newInstance(component, 0).getClass(); - } - return null; - } - - /** Resolved once: jakarta.validation is an optional dependency of this module. */ - private static final boolean CONSTRAINTS_PRESENT = ConstraintHints.available(); - - private static final class SchemaRegistry { - private static final Set> SIMPLE = Set.of( - String.class, CharSequence.class, - Boolean.class, Byte.class, Short.class, Integer.class, Long.class, Float.class, Double.class, - boolean.class, byte.class, short.class, int.class, long.class, float.class, double.class, - UUID.class, LocalDate.class, LocalDateTime.class, OffsetDateTime.class, Instant.class - ); - - private final Map, String> names = new LinkedHashMap<>(); - private final Map> docs = new LinkedHashMap<>(); - private final Set> resolving = new HashSet<>(); - - Map referenceFor(Class type) { - return schemaFor(type); - } - - Map schemaForType(Type type) { - return schemaFor(type); - } - - Map render() { - Map out = new LinkedHashMap<>(); - for (var e : docs.entrySet()) out.put(e.getKey(), e.getValue()); - return out; - } - - private Map schemaFor(Type type) { - if (type instanceof ParameterizedType p) { - Class raw = rawType(p); - if (raw != null && Collection.class.isAssignableFrom(raw)) { - Type item = p.getActualTypeArguments()[0]; - return Map.of("type", "array", "items", schemaFor(item)); - } - if (raw != null && Map.class.isAssignableFrom(raw)) { - Type value = p.getActualTypeArguments().length > 1 ? p.getActualTypeArguments()[1] : Object.class; - return Map.of("type", "object", "additionalProperties", schemaFor(value)); - } - if (raw != null) return schemaFor(raw); - } - - Class cls = rawType(type); - if (cls == null || cls == Object.class) return Map.of("type", "object"); - - if (cls.isArray()) return Map.of("type", "array", "items", schemaFor(cls.getComponentType())); - if (Collection.class.isAssignableFrom(cls)) return Map.of("type", "array", "items", Map.of("type", "object")); - if (Map.class.isAssignableFrom(cls)) return Map.of("type", "object", "additionalProperties", Map.of("type", "object")); - - Map simple = simpleSchema(cls); - if (simple != null) return simple; - - return Map.of("$ref", "#/components/schemas/" + registerPojo(cls)); - } - - private String registerPojo(Class cls) { - String existing = names.get(cls); - if (existing != null) return existing; - - String base = schemaName(cls); - String name = base; - int i = 2; - while (docs.containsKey(name)) name = base + i++; - names.put(cls, name); - - if (resolving.contains(cls)) return name; - - resolving.add(cls); - docs.put(name, buildPojoSchema(cls)); - resolving.remove(cls); - return name; - } - - private Map buildPojoSchema(Class cls) { - Schema typeSchema = cls.getAnnotation(Schema.class); - JsonIgnoreProperties ignoredType = cls.getAnnotation(JsonIgnoreProperties.class); - Set ignored = ignoredType == null - ? Set.of() - : new HashSet<>(Arrays.asList(ignoredType.value())); - - Map out = new LinkedHashMap<>(); - out.put("type", "object"); - if (typeSchema != null) applySchemaHints(out, typeSchema); - - Map properties = new LinkedHashMap<>(); - List required = new ArrayList<>(); - - for (Field f : cls.getDeclaredFields()) { - int mod = f.getModifiers(); - if (Modifier.isStatic(mod) || Modifier.isTransient(mod)) continue; - if (f.isAnnotationPresent(JsonIgnore.class)) continue; - if (ignored.contains(f.getName())) continue; - - String name = f.getName(); - JsonProperty jp = f.getAnnotation(JsonProperty.class); - if (jp != null && !jp.value().isEmpty()) name = jp.value(); - - Schema ps = f.getAnnotation(Schema.class); - SchemaProperty sp = f.getAnnotation(SchemaProperty.class); - ArraySchema array = f.getAnnotation(ArraySchema.class); - if ((ps != null && ps.hidden()) || (sp != null && sp.hidden())) continue; - - if (sp != null && !sp.name().isEmpty()) name = sp.name(); - - Map property = new LinkedHashMap<>(schemaFor(f.getGenericType())); - if (ps != null) applySchemaHints(property, ps); - if (sp != null) applySchemaHints(property, sp); - if (array != null) applyArrayHints(property, array); - if (jp != null) { - if (jp.access() == Access.READ_ONLY) property.put("readOnly", true); - if (jp.access() == Access.WRITE_ONLY) property.put("writeOnly", true); - } - - // Constraints declared for flash-ext-validation also describe the contract, so - // mirror them here rather than making callers restate every rule as @Schema. - boolean constrainedRequired = CONSTRAINTS_PRESENT && ConstraintHints.apply(f, property); - - properties.put(name, property); - if (constrainedRequired - || (ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required())) - required.add(name); - } - - if (!properties.isEmpty()) out.put("properties", properties); - if (!required.isEmpty()) out.put("required", required); - return out; - } - - private static void applySchemaHints(Map target, Schema schema) { - if (!schema.title().isEmpty()) target.put("title", schema.title()); - if (!schema.description().isEmpty()) target.put("description", schema.description()); - if (!schema.format().isEmpty()) target.put("format", schema.format()); - if (!schema.example().isEmpty()) target.put("example", schema.example()); - if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration())); - if (schema.nullable()) target.put("nullable", true); - if (schema.deprecated()) target.put("deprecated", true); - } - - private static void applySchemaHints(Map target, SchemaProperty schema) { - if (!schema.title().isEmpty()) target.put("title", schema.title()); - if (!schema.description().isEmpty()) target.put("description", schema.description()); - if (!schema.format().isEmpty()) target.put("format", schema.format()); - if (!schema.example().isEmpty()) target.put("example", schema.example()); - if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration())); - if (schema.nullable()) target.put("nullable", true); - if (schema.deprecated()) target.put("deprecated", true); - } - - private Map withArrayType(Map property, ArraySchema array) { - if ("array".equals(property.get("type"))) return property; - Type itemType = array.itemClass() != Void.class ? array.itemClass() : Object.class; - Map wrapped = new LinkedHashMap<>(); - wrapped.put("type", "array"); - wrapped.put("items", schemaFor(itemType)); - return wrapped; - } - - private void applyArrayHints(Map property, ArraySchema array) { - Map target = withArrayType(property, array); - if (target != property) { - property.clear(); - property.putAll(target); - } - if (array.uniqueItems()) property.put("uniqueItems", true); - if (array.minItems() >= 0) property.put("minItems", array.minItems()); - if (array.maxItems() >= 0) property.put("maxItems", array.maxItems()); - } - - private static String schemaName(Class cls) { - Schema schema = cls.getAnnotation(Schema.class); - if (schema != null && !schema.name().isEmpty()) return schema.name(); - return cls.getSimpleName(); - } - - private static Map simpleSchema(Class cls) { - if (!SIMPLE.contains(cls) && !cls.isEnum()) return null; - - if (cls == String.class || CharSequence.class.isAssignableFrom(cls)) return Map.of("type", "string"); - if (cls == Boolean.class || cls == boolean.class) return Map.of("type", "boolean"); - if (cls == Integer.class || cls == int.class || cls == Long.class || cls == long.class || - cls == Short.class || cls == short.class || cls == Byte.class || cls == byte.class) { - return Map.of("type", "integer"); - } - if (cls == Float.class || cls == float.class || cls == Double.class || cls == double.class) { - return Map.of("type", "number"); - } - if (cls == UUID.class) return Map.of("type", "string", "format", "uuid"); - if (cls == LocalDate.class) return Map.of("type", "string", "format", "date"); - if (cls == LocalDateTime.class || cls == OffsetDateTime.class || cls == Instant.class) - return Map.of("type", "string", "format", "date-time"); - if (cls.isEnum()) { - Object[] constants = cls.getEnumConstants(); - List values = new ArrayList<>(constants.length); - for (Object c : constants) values.add(String.valueOf(c)); - return Map.of("type", "string", "enum", values); - } - return null; - } - } - + /** The route a handler class declares, through {@code @Route} or any shorthand carrying it. */ static Route routeOf(Class cls) { Route direct = cls.getAnnotation(Route.class); if (direct != null) return direct; - for (Annotation ann : cls.getAnnotations()) { - Route meta = ann.annotationType().getAnnotation(Route.class); + + for (Annotation annotation : cls.getAnnotations()) { + Route meta = annotation.annotationType().getAnnotation(Route.class); if (meta == null) continue; - String path = readPathValue(ann); + String path = pathOf(annotation); if (path == null) continue; HttpMethod method = meta.method(); return new Route() { @@ -585,11 +465,10 @@ public final class OpenApiBuilder { return null; } - private static String readPathValue(Annotation ann) { + private static String pathOf(Annotation annotation) { try { - Object v = ann.annotationType().getMethod("value").invoke(ann); - return v instanceof String s ? s : null; - } catch (ReflectiveOperationException ignored) { + return annotation.annotationType().getMethod("value").invoke(annotation) instanceof String path ? path : null; + } catch (ReflectiveOperationException absent) { return null; } } diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java index e914e1d..49cd16c 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java @@ -10,7 +10,6 @@ import dev.relism.flash.extension.RouteEvent; import dev.relism.flash.http.ContentType; import dev.relism.flash.http.HttpMethod; import dev.relism.flash.routing.Route; -import lombok.extern.slf4j.Slf4j; /** * Generates and serves an OpenAPI 3.0 spec and Swagger UI under a configurable base path. @@ -25,8 +24,9 @@ import lombok.extern.slf4j.Slf4j; *

If {@code flash-ext-jackson} is installed, this extension reuses its * {@link ObjectMapper}. Otherwise it uses a local default mapper. * - *

Operations are collected at boot from handlers annotated with {@link ApiOperation} - * that also have route metadata ({@link Route} or shorthand verb annotations). + *

Every class-based route is collected at boot, whether or not it is annotated: a path, the + * body its handler takes and what it returns are already in the code. {@link ApiOperation} adds + * the prose. * *

{@code
  * FlashApp.create(8080)
@@ -35,7 +35,6 @@ import lombok.extern.slf4j.Slf4j;
  *     .start();
  * }
*/ -@Slf4j public class OpenApiExtension implements FlashExtension { private static final String YAML_CONTENT_TYPE = "application/yaml"; @@ -121,18 +120,12 @@ public class OpenApiExtension implements FlashExtension { ""; } + /** Every class-based route is an operation; {@link ApiOperation} only adds what the code cannot say. */ private static void addOperationFromEvent(OpenApiBuilder builder, RouteEvent event) { Class handlerClass = event.handlerClass(); - if (handlerClass == null) return; // lambda route: no annotation metadata + if (handlerClass == null) return; // a lambda route has nothing to read - ApiOperation op = handlerClass.getAnnotation(ApiOperation.class); - if (op == null) { - log.warn("{} {} ({}) has no @ApiOperation — omitted from the OpenAPI spec", - event.method(), event.path(), handlerClass.getSimpleName()); - return; - } - - builder.addOperation(routeOf(event), op, handlerClass); + builder.addOperation(routeOf(event), handlerClass.getAnnotation(ApiOperation.class), handlerClass); } private static Route routeOf(RouteEvent event) { diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/RequestBody.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/RequestBody.java new file mode 100644 index 0000000..707e52d --- /dev/null +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/RequestBody.java @@ -0,0 +1,31 @@ +package dev.relism.flash.ext.openapi; + +import dev.relism.flash.http.ContentType; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * The body this operation takes, for a handler that reads it by hand. + * + *

A handler extending {@code BodyHandler} — {@code JsonHandler} and its like — needs none of + * this: its body type is its type argument and its media type comes from {@code @Consumes}. Use + * this when the body is read straight off the request, or to describe it as something other than + * what the handler parses. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface RequestBody { + + Class value(); + + ContentType contentType() default ContentType.JSON; + + boolean array() default false; + + boolean required() default true; + + String description() default ""; +} diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Schemas.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Schemas.java new file mode 100644 index 0000000..1af3bc4 --- /dev/null +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Schemas.java @@ -0,0 +1,247 @@ +package dev.relism.flash.ext.openapi; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonProperty.Access; + +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * Every schema the document names, and the types they were built from. + * + *

A type is described once and referenced by {@code $ref} everywhere it appears, so a document + * over a hundred routes carries one copy of each model. What a field means comes from the type + * itself: Jackson's annotations decide what is exposed, {@code jakarta.validation} constraints + * become the schema's own bounds, and {@link Schema}/{@link SchemaProperty} say the rest. + */ +final class Schemas { + + /** Resolved once: jakarta.validation is an optional dependency of this module. */ + private static final boolean CONSTRAINTS_PRESENT = ConstraintHints.available(); + + private static final Set> SIMPLE = Set.of( + String.class, CharSequence.class, + Boolean.class, Byte.class, Short.class, Integer.class, Long.class, Float.class, Double.class, + boolean.class, byte.class, short.class, int.class, long.class, float.class, double.class, + UUID.class, LocalDate.class, LocalDateTime.class, OffsetDateTime.class, Instant.class + ); + + private final Map, String> names = new LinkedHashMap<>(); + private final Map> docs = new LinkedHashMap<>(); + private final Set> resolving = new HashSet<>(); + + Map referenceFor(Class type) { + return schemaFor(type); + } + + Map schemaForType(Type type) { + return schemaFor(type); + } + + Map render() { + Map out = new LinkedHashMap<>(); + for (var e : docs.entrySet()) out.put(e.getKey(), e.getValue()); + return out; + } + + private Map schemaFor(Type type) { + if (type instanceof ParameterizedType p) { + Class raw = rawType(p); + if (raw != null && Collection.class.isAssignableFrom(raw)) { + Type item = p.getActualTypeArguments()[0]; + return Map.of("type", "array", "items", schemaFor(item)); + } + if (raw != null && Map.class.isAssignableFrom(raw)) { + Type value = p.getActualTypeArguments().length > 1 ? p.getActualTypeArguments()[1] : Object.class; + return Map.of("type", "object", "additionalProperties", schemaFor(value)); + } + if (raw != null) return schemaFor(raw); + } + + Class cls = rawType(type); + if (cls == null || cls == Object.class) return Map.of("type", "object"); + + if (cls.isArray()) return Map.of("type", "array", "items", schemaFor(cls.getComponentType())); + if (Collection.class.isAssignableFrom(cls)) return Map.of("type", "array", "items", Map.of("type", "object")); + if (Map.class.isAssignableFrom(cls)) return Map.of("type", "object", "additionalProperties", Map.of("type", "object")); + + Map simple = simpleSchema(cls); + if (simple != null) return simple; + + return Map.of("$ref", "#/components/schemas/" + registerPojo(cls)); + } + + private String registerPojo(Class cls) { + String existing = names.get(cls); + if (existing != null) return existing; + + String base = schemaName(cls); + String name = base; + int i = 2; + while (docs.containsKey(name)) name = base + i++; + names.put(cls, name); + + if (resolving.contains(cls)) return name; + + resolving.add(cls); + docs.put(name, buildPojoSchema(cls)); + resolving.remove(cls); + return name; + } + + private Map buildPojoSchema(Class cls) { + Schema typeSchema = cls.getAnnotation(Schema.class); + JsonIgnoreProperties ignoredType = cls.getAnnotation(JsonIgnoreProperties.class); + Set ignored = ignoredType == null + ? Set.of() + : new HashSet<>(Arrays.asList(ignoredType.value())); + + Map out = new LinkedHashMap<>(); + out.put("type", "object"); + if (typeSchema != null) applySchemaHints(out, typeSchema); + + Map properties = new LinkedHashMap<>(); + List required = new ArrayList<>(); + + for (Field f : cls.getDeclaredFields()) { + int mod = f.getModifiers(); + if (Modifier.isStatic(mod) || Modifier.isTransient(mod)) continue; + if (f.isAnnotationPresent(JsonIgnore.class)) continue; + if (ignored.contains(f.getName())) continue; + + String name = f.getName(); + JsonProperty jp = f.getAnnotation(JsonProperty.class); + if (jp != null && !jp.value().isEmpty()) name = jp.value(); + + Schema ps = f.getAnnotation(Schema.class); + SchemaProperty sp = f.getAnnotation(SchemaProperty.class); + ArraySchema array = f.getAnnotation(ArraySchema.class); + if ((ps != null && ps.hidden()) || (sp != null && sp.hidden())) continue; + + if (sp != null && !sp.name().isEmpty()) name = sp.name(); + + Map property = new LinkedHashMap<>(schemaFor(f.getGenericType())); + if (ps != null) applySchemaHints(property, ps); + if (sp != null) applySchemaHints(property, sp); + if (array != null) applyArrayHints(property, array); + if (jp != null) { + if (jp.access() == Access.READ_ONLY) property.put("readOnly", true); + if (jp.access() == Access.WRITE_ONLY) property.put("writeOnly", true); + } + + // Constraints a body is checked against also describe it, so + // mirror them here rather than making callers restate every rule as @Schema. + boolean constrainedRequired = CONSTRAINTS_PRESENT && ConstraintHints.apply(f, property); + + properties.put(name, property); + if (constrainedRequired + || (ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required())) + required.add(name); + } + + if (!properties.isEmpty()) out.put("properties", properties); + if (!required.isEmpty()) out.put("required", required); + return out; + } + + private static void applySchemaHints(Map target, Schema schema) { + if (!schema.title().isEmpty()) target.put("title", schema.title()); + if (!schema.description().isEmpty()) target.put("description", schema.description()); + if (!schema.format().isEmpty()) target.put("format", schema.format()); + if (!schema.example().isEmpty()) target.put("example", schema.example()); + if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration())); + if (schema.nullable()) target.put("nullable", true); + if (schema.deprecated()) target.put("deprecated", true); + } + + private static void applySchemaHints(Map target, SchemaProperty schema) { + if (!schema.title().isEmpty()) target.put("title", schema.title()); + if (!schema.description().isEmpty()) target.put("description", schema.description()); + if (!schema.format().isEmpty()) target.put("format", schema.format()); + if (!schema.example().isEmpty()) target.put("example", schema.example()); + if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration())); + if (schema.nullable()) target.put("nullable", true); + if (schema.deprecated()) target.put("deprecated", true); + } + + private Map withArrayType(Map property, ArraySchema array) { + if ("array".equals(property.get("type"))) return property; + Type itemType = array.itemClass() != Void.class ? array.itemClass() : Object.class; + Map wrapped = new LinkedHashMap<>(); + wrapped.put("type", "array"); + wrapped.put("items", schemaFor(itemType)); + return wrapped; + } + + private void applyArrayHints(Map property, ArraySchema array) { + Map target = withArrayType(property, array); + if (target != property) { + property.clear(); + property.putAll(target); + } + if (array.uniqueItems()) property.put("uniqueItems", true); + if (array.minItems() >= 0) property.put("minItems", array.minItems()); + if (array.maxItems() >= 0) property.put("maxItems", array.maxItems()); + } + + private static String schemaName(Class cls) { + Schema schema = cls.getAnnotation(Schema.class); + if (schema != null && !schema.name().isEmpty()) return schema.name(); + return cls.getSimpleName(); + } + + private static Map simpleSchema(Class cls) { + if (!SIMPLE.contains(cls) && !cls.isEnum()) return null; + + if (cls == String.class || CharSequence.class.isAssignableFrom(cls)) return Map.of("type", "string"); + if (cls == Boolean.class || cls == boolean.class) return Map.of("type", "boolean"); + if (cls == Integer.class || cls == int.class || cls == Long.class || cls == long.class || + cls == Short.class || cls == short.class || cls == Byte.class || cls == byte.class) { + return Map.of("type", "integer"); + } + if (cls == Float.class || cls == float.class || cls == Double.class || cls == double.class) { + return Map.of("type", "number"); + } + if (cls == UUID.class) return Map.of("type", "string", "format", "uuid"); + if (cls == LocalDate.class) return Map.of("type", "string", "format", "date"); + if (cls == LocalDateTime.class || cls == OffsetDateTime.class || cls == Instant.class) + return Map.of("type", "string", "format", "date-time"); + if (cls.isEnum()) { + Object[] constants = cls.getEnumConstants(); + List values = new ArrayList<>(constants.length); + for (Object c : constants) values.add(String.valueOf(c)); + return Map.of("type", "string", "enum", values); + } + return null; + } + + +static Class rawType(Type type) { + if (type instanceof Class c) return c; + if (type instanceof ParameterizedType p && p.getRawType() instanceof Class c) return c; + if (type instanceof GenericArrayType a) { + Class component = rawType(a.getGenericComponentType()); + return component == null ? null : Array.newInstance(component, 0).getClass(); + } + return null; +} +} diff --git a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java index b3f79aa..40baa7b 100644 --- a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java +++ b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java @@ -133,6 +133,150 @@ class OpenApiBuilderTest { public List tags; } + @dev.relism.flash.routing.POST("/bodies") + @ApiOperation(summary = "Typed body") + static final class TypedBodyHandler extends JsonLikeHandler { + @Override protected UserDto handle(Request request, Response response, UserDto body) { return body; } + } + + @dev.relism.flash.routing.Consumes(ContentType.JSON) + static abstract class JsonLikeHandler extends dev.relism.flash.models.BodyHandler { + @Override protected B body(Request request) { return null; } + } + + @dev.relism.flash.routing.PUT("/declared-body") + @ApiOperation(summary = "Declared body") + @RequestBody(value = UserDto.class, array = true, required = false, description = "Users to store") + static class DeclaredBodyHandler extends RequestHandler { + @Override public Object handle(Request request, Response response) { return null; } + } + + @GET("/bare") + static class BareHandler extends RequestHandler { + @Override public UserDto handle(Request request, Response response) { return null; } + } + + @GET("/example") + @ApiOperation(summary = "Example") + @APIResponse(responseCode = "200", content = @Content(schema = UserDto.class, example = "{\"id\":\"usr-1\"}")) + static class ExampleHandler extends RequestHandler { + @Override public Object handle(Request request, Response response) { return null; } + } + + @GET("/secure-too") + @ApiOperation(summary = "Secure too") + static class SecondSecureHandler extends RequestHandler { + @Override public Object handle(Request request, Response response) { return null; } + } + + @Test + void a_typed_handler_documents_its_body_without_saying_the_type_twice() { + OpenApiBuilder b = new OpenApiBuilder(); + b.addOperation(OpenApiBuilder.routeOf(TypedBodyHandler.class), TypedBodyHandler.class.getAnnotation(ApiOperation.class), TypedBodyHandler.class); + + Map post = getOperation(b.build(), "/bodies", "post"); + Map body = cast(post.get("requestBody")); + Map content = cast(body.get("content")); + Map json = cast(content.get("application/json")); + Map schema = cast(json.get("schema")); + + assertEquals(true, body.get("required")); + assertEquals("#/components/schemas/UserDTO", schema.get("$ref")); + } + + @Test + void a_declared_body_wins_and_carries_its_own_shape() { + OpenApiBuilder b = new OpenApiBuilder(); + b.addOperation(OpenApiBuilder.routeOf(DeclaredBodyHandler.class), DeclaredBodyHandler.class.getAnnotation(ApiOperation.class), DeclaredBodyHandler.class); + + Map put = getOperation(b.build(), "/declared-body", "put"); + Map body = cast(put.get("requestBody")); + Map content = cast(body.get("content")); + Map json = cast(content.get("application/json")); + Map schema = cast(json.get("schema")); + + assertEquals("Users to store", body.get("description")); + assertEquals(false, body.get("required")); + assertEquals("array", schema.get("type")); + } + + @Test + void a_route_with_no_annotations_is_still_documented() { + OpenApiBuilder b = new OpenApiBuilder(); + b.addOperation(OpenApiBuilder.routeOf(BareHandler.class), null, BareHandler.class); + + Map get = getOperation(b.build(), "/bare", "get"); + Map responses = cast(get.get("responses")); + Map ok = cast(responses.get("200")); + Map content = cast(ok.get("content")); + Map json = cast(content.get("application/json")); + Map schema = cast(json.get("schema")); + + assertEquals("#/components/schemas/UserDTO", schema.get("$ref")); + } + + @Test + void a_failure_is_documented_with_the_shape_flash_answers_with() { + OpenApiBuilder b = new OpenApiBuilder(); + b.addOperation(OpenApiBuilder.routeOf(SecureHandler.class), SecureHandler.class.getAnnotation(ApiOperation.class), SecureHandler.class); + + Map spec = b.build(); + Map get = getOperation(spec, "/secure", "get"); + Map responses = cast(get.get("responses")); + Map forbidden = cast(responses.get("403")); + Map content = cast(forbidden.get("content")); + Map json = cast(content.get("application/json")); + Map schema = cast(json.get("schema")); + Map components = cast(spec.get("components")); + Map schemas = cast(components.get("schemas")); + + assertEquals("#/components/schemas/Error", schema.get("$ref")); + assertTrue(schemas.containsKey("Error")); + } + + @Test + void an_answer_two_operations_share_is_written_once() { + OpenApiBuilder b = new OpenApiBuilder(); + OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); + registry.add(new OpenApiContributor() { + @Override public OpenApiOperationContribution operationFor(Class handlerClass) { + return OpenApiOperationContribution.builder() + .response(401, OpenApiResponseContribution.of("Authentication required")) + .build(); + } + }); + b.setContributorRegistry(registry); + b.addOperation(OpenApiBuilder.routeOf(SecureHandler.class), SecureHandler.class.getAnnotation(ApiOperation.class), SecureHandler.class); + b.addOperation(OpenApiBuilder.routeOf(SecondSecureHandler.class), SecondSecureHandler.class.getAnnotation(ApiOperation.class), SecondSecureHandler.class); + + Map spec = b.build(); + Map components = cast(spec.get("components")); + Map shared = cast(components.get("responses")); + Map firstResponses = cast(getOperation(spec, "/secure", "get").get("responses")); + Map secondResponses = cast(getOperation(spec, "/secure-too", "get").get("responses")); + Map first = cast(firstResponses.get("401")); + Map second = cast(secondResponses.get("401")); + Map unauthorized = cast(shared.get("Unauthorized")); + + assertEquals("#/components/responses/Unauthorized", first.get("$ref")); + assertEquals("#/components/responses/Unauthorized", second.get("$ref")); + assertEquals("Authentication required", unauthorized.get("description")); + } + + @Test + void an_example_sits_beside_the_schema() { + OpenApiBuilder b = new OpenApiBuilder(); + b.addOperation(OpenApiBuilder.routeOf(ExampleHandler.class), ExampleHandler.class.getAnnotation(ApiOperation.class), ExampleHandler.class); + + Map get = getOperation(b.build(), "/example", "get"); + Map responses = cast(get.get("responses")); + Map ok = cast(responses.get("200")); + Map content = cast(ok.get("content")); + Map json = cast(content.get("application/json")); + + assertEquals("{\"id\":\"usr-1\"}", json.get("example")); + } + @Test void builds_single_response_and_parameters_and_schema() { OpenApiBuilder b = new OpenApiBuilder().title("X").version("1"); diff --git a/flash-extensions/flash-ext-validation/docs/README.md b/flash-extensions/flash-ext-validation/docs/README.md deleted file mode 100644 index 09f5076..0000000 --- a/flash-extensions/flash-ext-validation/docs/README.md +++ /dev/null @@ -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 - - dev.relism - flash-ext-validation - ${flash.version} - -``` - -## 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. diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java deleted file mode 100644 index df692c2..0000000 --- a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java +++ /dev/null @@ -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)}. - * - *

{@code
- * CreateUser dto = validation.body(req, CreateUser.class);   // parse + verify
- * }
- * - *

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 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 body(Request request, Class 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 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); - } -} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java deleted file mode 100644 index 029594b..0000000 --- a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java +++ /dev/null @@ -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. - * - *

{@code
- * FlashApp.create(8080)
- *     .install(new JacksonExtension())
- *     .install(new ValidationExtension())
- *     .scan("dev.example.api");
- * }
- * - *

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. - * - *

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))); - } -} diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index 986162c..b0129eb 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -14,7 +14,9 @@ pom - flash-ext-jackson + flash-ext-jackson-core + flash-ext-jackson-json + flash-ext-jackson-xml flash-ext-openapi flash-ext-security-core flash-ext-security-oidc @@ -30,7 +32,6 @@ flash-ext-vite flash-ext-vite-maven-plugin flash-ext-mcp - flash-ext-validation flash-ext-scheduler flash-ext-data-core flash-ext-data-jdbc @@ -41,11 +42,6 @@ - - dev.relism - flash-ext-validation - ${project.version} - dev.relism flash-ext-scheduler @@ -85,7 +81,17 @@ dev.relism - flash-ext-jackson + flash-ext-jackson-core + ${project.version} + + + dev.relism + flash-ext-jackson-json + ${project.version} + + + dev.relism + flash-ext-jackson-xml ${project.version} @@ -98,6 +104,11 @@ jackson-databind 2.17.2 + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + 2.17.2 + com.fasterxml.jackson.dataformat jackson-dataformat-yaml diff --git a/flash/src/main/java/dev/relism/flash/extension/Inject.java b/flash/src/main/java/dev/relism/flash/extension/Inject.java new file mode 100644 index 0000000..d1ce903 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/extension/Inject.java @@ -0,0 +1,36 @@ +package dev.relism.flash.extension; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * A service this handler needs, filled in once when the handler is bound. + * + *

{@code
+ * public final class ListUsers extends RequestHandler {
+ *     @Inject private UserService users;
+ *
+ *     @Override public Object handle(Request req, Response res) { return users.findAll(); }
+ * }
+ * }
+ * + *

This is how a handler takes a service. {@code onInit} stays for what has to be computed at + * boot, or for a service that may not be there ({@code find}). + * + *

What it does, exactly

+ *
    + *
  • Filled inside {@code bind}, before {@code onInit}, once per handler instance when + * the route is registered. Never per request: the request path reads a field.
  • + *
  • Every annotated field of the handler and of its bases up to {@code RequestHandler} is + * filled, {@code private} included.
  • + *
  • The service is looked up by the field's declared type, exactly — not a supertype, + * not a generic parameter. A type nothing provides fails the boot, naming the field.
  • + *
  • A {@code static} field is refused: it would be shared by every handler. A {@code final} + * one is refused too: it is written after construction, and a reader may have folded it.
  • + *
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Inject {} diff --git a/flash/src/main/java/dev/relism/flash/models/BodyHandler.java b/flash/src/main/java/dev/relism/flash/models/BodyHandler.java new file mode 100644 index 0000000..2dc440b --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/models/BodyHandler.java @@ -0,0 +1,80 @@ +package dev.relism.flash.models; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.util.HashMap; +import java.util.Map; + +/** + * A handler whose request carries a body of a known type. + * + *

The type is the class's own type argument, so it is written once, in the signature: + * + *

{@code
+ * @POST("/users")
+ * public final class CreateUser extends JsonHandler {
+ *     @Override protected Object handle(Request req, Response res, NewUser body) {
+ *         return users.create(body);
+ *     }
+ * }
+ * }
+ * + *

Reading the body is the format's job: a subclass such as {@code JsonHandler} implements + * {@link #body} and declares its media type with + * {@link dev.relism.flash.routing.Consumes @Consumes}. Documentation tools read both off the + * class, which is what lets a request body be described without saying its type a second time. + * + * @param the body type + */ +public abstract class BodyHandler extends RequestHandler { + + /** + * The body type a handler class declares, or {@code null} when it leaves it open. + * + *

Resolved through the whole chain, so an intermediate base class that passes its own type + * argument along answers with the type its subclass fixed. + */ + public static Class bodyTypeOf(Class handlerClass) { + Map, Type> bound = new HashMap<>(); + + for (Class current = handlerClass; current != null && current != Object.class; ) { + if (!(current.getGenericSuperclass() instanceof ParameterizedType parameterized)) { + current = current.getSuperclass(); + continue; + } + Class raw = (Class) parameterized.getRawType(); + Type[] arguments = parameterized.getActualTypeArguments(); + TypeVariable[] variables = raw.getTypeParameters(); + + for (int i = 0; i < variables.length && i < arguments.length; i++) { + bound.put(variables[i], resolve(arguments[i], bound)); + } + if (raw == BodyHandler.class) { + return resolve(arguments[0], bound) instanceof Class type ? type : null; + } + current = raw; + } + return null; + } + + private static Type resolve(Type type, Map, Type> bound) { + return type instanceof TypeVariable variable ? bound.getOrDefault(variable, type) : type; + } + + /** This handler's body type, for the reader in {@link #body}. Null only on a handler left generic. */ + @SuppressWarnings("unchecked") + protected final Class bodyType() { + return (Class) bodyTypeOf(getClass()); + } + + /** Reads the body in the format this handler speaks. */ + protected abstract B body(Request request) throws Exception; + + protected abstract Object handle(Request request, Response response, B body) throws Exception; + + @Override + public final Object handle(Request request, Response response) throws Exception { + return handle(request, response, body(request)); + } +} diff --git a/flash/src/main/java/dev/relism/flash/models/RequestHandler.java b/flash/src/main/java/dev/relism/flash/models/RequestHandler.java index d8ae5bf..609fa63 100644 --- a/flash/src/main/java/dev/relism/flash/models/RequestHandler.java +++ b/flash/src/main/java/dev/relism/flash/models/RequestHandler.java @@ -2,8 +2,11 @@ package dev.relism.flash.models; import dev.relism.flash.extension.FlashApp; import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.extension.Inject; import dev.relism.flash.routing.Route; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; import java.util.Optional; /** @@ -22,8 +25,9 @@ import java.util.Optional; * * *

Service access

- * Override {@link #onInit()} to cache services from the {@link FlashContext} - * into private fields. This keeps the hot-path ({@code handle}) free of map lookups. + * Annotate a field with {@link Inject} and it is filled at boot, or override {@link #onInit()} + * to cache services from the {@link FlashContext} by hand. Either way the hot path + * ({@code handle}) reads a field and never the context. * *
{@code
  * @GET("/users")
@@ -53,9 +57,41 @@ public abstract class RequestHandler {
      */
     public final void bind(FlashContext ctx) {
         this.ctx = ctx;
+        inject();
         onInit();
     }
 
+    /** Fills every {@link Inject} field, this class's and its bases', before {@link #onInit}. */
+    private void inject() {
+        for (Class type = getClass(); type != null && type != RequestHandler.class; type = type.getSuperclass()) {
+            for (Field field : type.getDeclaredFields()) {
+                if (!field.isAnnotationPresent(Inject.class)) continue;
+                String where = type.getSimpleName() + "." + field.getName();
+
+                // A static field would be shared by every handler, and a final one may already have
+                // been folded into the code that reads it. Both are refused rather than surprising.
+                if (Modifier.isStatic(field.getModifiers()))
+                    throw new IllegalStateException(where + " is static: an injected field belongs to the handler");
+                if (Modifier.isFinal(field.getModifiers()))
+                    throw new IllegalStateException(where + " is final: an injected field is written after construction");
+
+                Object service;
+                try {
+                    service = ctx.require(field.getType());
+                } catch (RuntimeException missing) {
+                    throw new IllegalStateException(where + " asks for " + field.getType().getSimpleName()
+                            + ", which nothing provides", missing);
+                }
+                try {
+                    field.setAccessible(true);
+                    field.set(this, service);
+                } catch (ReflectiveOperationException | RuntimeException unreachable) {
+                    throw new IllegalStateException("Could not write " + where, unreachable);
+                }
+            }
+        }
+    }
+
     /**
      * Override to cache services at boot time. Called once after {@link #bind},
      * before any request reaches this handler.
diff --git a/flash/src/main/java/dev/relism/flash/routing/Consumes.java b/flash/src/main/java/dev/relism/flash/routing/Consumes.java
new file mode 100644
index 0000000..a281b41
--- /dev/null
+++ b/flash/src/main/java/dev/relism/flash/routing/Consumes.java
@@ -0,0 +1,22 @@
+package dev.relism.flash.routing;
+
+import dev.relism.flash.http.ContentType;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * The media type a handler reads its request body as.
+ *
+ * 

Declared once on a handler base class — a JSON one, an XML one — and inherited by every + * handler written against it, so nothing has to repeat it. Tooling reads it; the router does not. + */ +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Consumes { + ContentType value(); +} diff --git a/flash/src/test/java/dev/relism/flash/models/BodyHandlerTest.java b/flash/src/test/java/dev/relism/flash/models/BodyHandlerTest.java new file mode 100644 index 0000000..02bb11c --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/models/BodyHandlerTest.java @@ -0,0 +1,109 @@ +package dev.relism.flash.models; + +import dev.relism.flash.http.ContentType; +import dev.relism.flash.http.HttpMethod; +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.assertNull; + +class BodyHandlerTest { + + record Payload(String text) {} + + static abstract class TextHandler extends BodyHandler { + @Override protected B body(Request request) { + return read(new String(request.body().bytes(), StandardCharsets.UTF_8)); + } + protected abstract B read(String text); + } + + static final class Echo extends TextHandler { + @Override protected Payload read(String text) { return new Payload(text); } + @Override protected Object handle(Request request, Response response, Payload body) { return body.text(); } + } + + static final class Raw extends BodyHandler { + @Override protected Object body(Request request) { return null; } + @Override protected Object handle(Request request, Response response, Object body) { return null; } + } + + @Test + void theBodyTypeIsReadThroughTheWholeChain() { + assertEquals(Payload.class, BodyHandler.bodyTypeOf(Echo.class), "resolved past the intermediate base"); + assertEquals(Object.class, BodyHandler.bodyTypeOf(Raw.class)); + assertNull(BodyHandler.bodyTypeOf(RequestHandler.class), "a handler with no body declares none"); + } + + @Test + void theBodyIsReadBeforeTheHandlerSeesIt() throws Exception { + Object answer = new Echo().handle(request("hello"), new Response(200, ContentType.NONE)); + + assertEquals("hello", answer); + } + + static final class Injected extends RequestHandler { + @dev.relism.flash.extension.Inject private String service; + @Override public Object handle(Request request, Response response) { return service; } + } + + static final class Missing extends RequestHandler { + @dev.relism.flash.extension.Inject private Integer absent; + @Override public Object handle(Request request, Response response) { return absent; } + } + + @Test + void an_injected_field_is_filled_before_the_handler_runs() throws Exception { + dev.relism.flash.extension.FlashContext ctx = new dev.relism.flash.extension.FlashContext(); + ctx.provide(String.class, "provided"); + ctx.complete(); + + Injected handler = new Injected(); + handler.bind(ctx); + + assertEquals("provided", handler.handle(request(""), new Response(200, ContentType.NONE))); + } + + static final class Shared extends RequestHandler { + @dev.relism.flash.extension.Inject private static String service; + @Override public Object handle(Request request, Response response) { return service; } + } + + static final class Frozen extends RequestHandler { + @dev.relism.flash.extension.Inject private final String service = ""; + @Override public Object handle(Request request, Response response) { return service; } + } + + @Test + void a_static_or_final_field_is_refused() { + dev.relism.flash.extension.FlashContext ctx = new dev.relism.flash.extension.FlashContext(); + ctx.provide(String.class, "provided"); + ctx.complete(); + + org.junit.jupiter.api.Assertions.assertTrue(org.junit.jupiter.api.Assertions.assertThrows( + IllegalStateException.class, () -> new Shared().bind(ctx)).getMessage().contains("is static")); + org.junit.jupiter.api.Assertions.assertTrue(org.junit.jupiter.api.Assertions.assertThrows( + IllegalStateException.class, () -> new Frozen().bind(ctx)).getMessage().contains("is final")); + } + + @Test + void a_field_nothing_provides_fails_the_boot_naming_it() { + dev.relism.flash.extension.FlashContext ctx = new dev.relism.flash.extension.FlashContext(); + ctx.complete(); + + IllegalStateException refused = org.junit.jupiter.api.Assertions.assertThrows( + IllegalStateException.class, () -> new Missing().bind(ctx)); + + org.junit.jupiter.api.Assertions.assertTrue(refused.getMessage().contains("Missing.absent")); + } + + private static Request request(String body) { + return new Request(new RequestLine(HttpMethod.POST, + new FastPathViews.StringByteView("/echo"), null, + new FastPathViews.StringByteView("HTTP/1.1"), new Http1HeaderMap()), + body.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/pom.xml b/pom.xml index e06dad3..47f4497 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,11 @@ + dev.relism flash @@ -66,12 +71,47 @@ dev.relism - flash-testing + flash-ext-security-core ${project.version} dev.relism - flash-ext-validation + flash-ext-security-apikey + ${project.version} + + + dev.relism + flash-ext-security-form + ${project.version} + + + dev.relism + flash-ext-security-oauth-server + ${project.version} + + + dev.relism + flash-ext-security-test + ${project.version} + + + dev.relism + flash-ext-data-core + ${project.version} + + + dev.relism + flash-ext-data-jdbc + ${project.version} + + + dev.relism + flash-ext-data-hibernate + ${project.version} + + + dev.relism + flash-testing ${project.version} @@ -96,7 +136,17 @@ dev.relism - flash-ext-jackson + flash-ext-jackson-core + ${project.version} + + + dev.relism + flash-ext-jackson-json + ${project.version} + + + dev.relism + flash-ext-jackson-xml ${project.version}