From ef4740f26d0381f87657babd662dc69da5befb85 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 23 Sep 2026 13:31:13 +0000 Subject: [PATCH 1/3] feat(core): a service a handler asks for, and a body typed in its signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things every handler was writing by hand. @Inject on a field is filled inside bind, before onInit, once per handler at boot: the request path still reads a field. The service is looked up by the field's exact declared type; a static or final field is refused, and a type nothing provides fails the boot naming the field. onInit stays for what has to be computed, or for a service that may not be there. BodyHandler puts the body type in the signature — handle(req, res, body) — and leaves reading it to the format. bodyTypeOf resolves that type argument through a whole chain of bases, so tooling can read off a class what a route takes. @Consumes says in which media type, inherited from the base class that implements the reading, and is descriptive: the router does not enforce it. Co-Authored-By: Claude Opus 5 --- .../dev/relism/flash/ext/jackson}/Check.java | 0 .../ext/jackson}/ValidationException.java | 0 .../relism/flash/ext/jackson}/Validator.java | 0 .../flash/ext/jackson}/ValidatorTest.java | 0 .../json/ConstraintsInTheDocumentTest.java} | 0 .../ext/jackson/json/JsonExtensionTest.java} | 0 .../flash/ext/jackson/json}/JsonTest.java | 0 .../ext/jackson/json/MarshallingTest.java} | 0 .../ext/jackson/json/ValidatedBodyTest.java} | 0 .../dev/relism/flash/extension/Inject.java | 36 ++++++ .../dev/relism/flash/models/BodyHandler.java | 80 +++++++++++++ .../relism/flash/models/RequestHandler.java | 40 ++++++- .../dev/relism/flash/routing/Consumes.java | 22 ++++ .../relism/flash/models/BodyHandlerTest.java | 109 ++++++++++++++++++ 14 files changed, 285 insertions(+), 2 deletions(-) rename flash-extensions/{flash-ext-validation/src/main/java/dev/relism/flash/ext/validation => flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson}/Check.java (100%) rename flash-extensions/{flash-ext-validation/src/main/java/dev/relism/flash/ext/validation => flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson}/ValidationException.java (100%) rename flash-extensions/{flash-ext-validation/src/main/java/dev/relism/flash/ext/validation => flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson}/Validator.java (100%) rename flash-extensions/{flash-ext-validation/src/test/java/dev/relism/flash/ext/validation => flash-ext-jackson-core/src/test/java/dev/relism/flash/ext/jackson}/ValidatorTest.java (100%) rename flash-extensions/{flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java => flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/ConstraintsInTheDocumentTest.java} (100%) rename flash-extensions/{flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonExtensionTest.java => flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/JsonExtensionTest.java} (100%) rename flash-extensions/{flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson => flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json}/JsonTest.java (100%) rename flash-extensions/{flash-ext-jackson/src/test/java/dev/relism/flash/ext/jackson/JacksonMiddlewareTest.java => flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/MarshallingTest.java} (100%) rename flash-extensions/{flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java => flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/ValidatedBodyTest.java} (100%) create mode 100644 flash/src/main/java/dev/relism/flash/extension/Inject.java create mode 100644 flash/src/main/java/dev/relism/flash/models/BodyHandler.java create mode 100644 flash/src/main/java/dev/relism/flash/routing/Consumes.java create mode 100644 flash/src/test/java/dev/relism/flash/models/BodyHandlerTest.java 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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)); + } +} -- 2.54.0 From adcd6376b6aa822d8f3afa8659f2a3be5b901983 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 23 Sep 2026 13:31:28 +0000 Subject: [PATCH 2/3] feat(ext-jackson): one module per data format, bodies checked on the way in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flash-ext-jackson and flash-ext-validation become three modules: - flash-ext-jackson-core: the Codec (one mapper, body/write/writeView), JacksonHandler, the outbound marshalling, and the constraint engine that used to be flash-ext-validation - flash-ext-jackson-json: Json, JsonExtension, JsonHandler - flash-ext-jackson-xml: Xml, XmlExtension, XmlHandler Every Jackson format is the same databind model behind a different factory, so the annotations, the constraints and the published schema are the same for all of them: only the mapper and the content type differ, and that is all a format module says. A route picks its format by the handler it extends — there is no negotiation and nothing to configure. A typed body is now always verified against its own type's jakarta constraints, whatever the format: malformed is a 400, a broken constraint is a 422, and neither reaches the handler. The validator was already allocation-free and stays so; validating is no longer something an application remembers to do. bodyFrom is gone. body has the streaming semantics, because the request's stream is reused per connection while bytes() allocates the whole body: one name, the path that does not allocate. JacksonExtension is JsonExtension, and autoJson() is auto(). The root POM now manages every module of this build, so anything composing Flash imports it once and never names a version again. Co-Authored-By: Claude Opus 5 --- README.md | 10 +- .../flash-ext-jackson-core/README.md | 66 ++++++++ .../flash-ext-jackson-core/pom.xml | 47 ++++++ .../dev/relism/flash/ext/jackson/Check.java | 2 +- .../dev/relism/flash/ext/jackson/Codec.java | 72 +++++++++ .../flash/ext/jackson/JacksonHandler.java | 33 ++++ .../relism/flash/ext/jackson/Marshalling.java | 32 ++++ .../ext/jackson/ValidationException.java | 4 +- .../relism/flash/ext/jackson/Validator.java | 28 +++- .../flash/ext/jackson/ValidatorTest.java | 2 +- .../flash-ext-jackson-json/README.md | 60 ++++++++ .../flash-ext-jackson-json/pom.xml | 40 +++++ .../relism/flash/ext/jackson/json/Json.java | 27 ++++ .../flash/ext/jackson/json/JsonExtension.java | 50 ++++++ .../flash/ext/jackson/json/JsonHandler.java | 35 +++++ .../json/ConstraintsInTheDocumentTest.java | 13 +- .../ext/jackson/json/JsonExtensionTest.java | 13 +- .../ext/jackson/json/JsonHandlerTest.java | 78 ++++++++++ .../flash/ext/jackson/json/JsonTest.java | 11 +- .../ext/jackson/json/MarshallingTest.java | 24 +-- .../ext/jackson/json/ValidatedBodyTest.java | 12 +- .../flash-ext-jackson-xml/README.md | 30 ++++ .../pom.xml | 34 ++--- .../dev/relism/flash/ext/jackson/xml/Xml.java | 20 +++ .../flash/ext/jackson/xml/XmlExtension.java | 44 ++++++ .../flash/ext/jackson/xml/XmlHandler.java | 24 +++ .../flash/ext/jackson/xml/XmlHandlerTest.java | 81 ++++++++++ flash-extensions/flash-ext-jackson/README.md | 116 -------------- flash-extensions/flash-ext-jackson/pom.xml | 82 ---------- .../flash/ext/jackson/JacksonExtension.java | 94 ------------ .../flash/ext/jackson/JacksonMiddleware.java | 62 -------- .../dev/relism/flash/ext/jackson/Json.java | 117 -------------- .../flash-ext-validation/docs/README.md | 143 ------------------ .../flash/ext/validation/Validation.java | 69 --------- .../ext/validation/ValidationExtension.java | 37 ----- flash-extensions/pom.xml | 27 +++- pom.xml | 56 ++++++- 37 files changed, 894 insertions(+), 801 deletions(-) create mode 100644 flash-extensions/flash-ext-jackson-core/README.md create mode 100644 flash-extensions/flash-ext-jackson-core/pom.xml create mode 100644 flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Codec.java create mode 100644 flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/JacksonHandler.java create mode 100644 flash-extensions/flash-ext-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Marshalling.java create mode 100644 flash-extensions/flash-ext-jackson-json/README.md create mode 100644 flash-extensions/flash-ext-jackson-json/pom.xml create mode 100644 flash-extensions/flash-ext-jackson-json/src/main/java/dev/relism/flash/ext/jackson/json/Json.java create mode 100644 flash-extensions/flash-ext-jackson-json/src/main/java/dev/relism/flash/ext/jackson/json/JsonExtension.java create mode 100644 flash-extensions/flash-ext-jackson-json/src/main/java/dev/relism/flash/ext/jackson/json/JsonHandler.java create mode 100644 flash-extensions/flash-ext-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/JsonHandlerTest.java create mode 100644 flash-extensions/flash-ext-jackson-xml/README.md rename flash-extensions/{flash-ext-validation => flash-ext-jackson-xml}/pom.xml (57%) create mode 100644 flash-extensions/flash-ext-jackson-xml/src/main/java/dev/relism/flash/ext/jackson/xml/Xml.java create mode 100644 flash-extensions/flash-ext-jackson-xml/src/main/java/dev/relism/flash/ext/jackson/xml/XmlExtension.java create mode 100644 flash-extensions/flash-ext-jackson-xml/src/main/java/dev/relism/flash/ext/jackson/xml/XmlHandler.java create mode 100644 flash-extensions/flash-ext-jackson-xml/src/test/java/dev/relism/flash/ext/jackson/xml/XmlHandlerTest.java delete mode 100644 flash-extensions/flash-ext-jackson/README.md delete mode 100644 flash-extensions/flash-ext-jackson/pom.xml delete mode 100644 flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonExtension.java delete mode 100644 flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/JacksonMiddleware.java delete mode 100644 flash-extensions/flash-ext-jackson/src/main/java/dev/relism/flash/ext/jackson/Json.java delete mode 100644 flash-extensions/flash-ext-validation/docs/README.md delete mode 100644 flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java delete mode 100644 flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java 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-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Check.java b/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-jackson-core/src/main/java/dev/relism/flash/ext/jackson/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-jackson-core/src/main/java/dev/relism/flash/ext/jackson/ValidationException.java b/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-jackson-core/src/main/java/dev/relism/flash/ext/jackson/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-jackson-core/src/main/java/dev/relism/flash/ext/jackson/Validator.java b/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-jackson-core/src/main/java/dev/relism/flash/ext/jackson/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-jackson-core/src/test/java/dev/relism/flash/ext/jackson/ValidatorTest.java b/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-jackson-core/src/test/java/dev/relism/flash/ext/jackson/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-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/ConstraintsInTheDocumentTest.java b/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-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/ConstraintsInTheDocumentTest.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-json/src/test/java/dev/relism/flash/ext/jackson/json/JsonExtensionTest.java b/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-json/src/test/java/dev/relism/flash/ext/jackson/json/JsonExtensionTest.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-json/src/test/java/dev/relism/flash/ext/jackson/json/JsonTest.java b/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-json/src/test/java/dev/relism/flash/ext/jackson/json/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-json/src/test/java/dev/relism/flash/ext/jackson/json/MarshallingTest.java b/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-json/src/test/java/dev/relism/flash/ext/jackson/json/MarshallingTest.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-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/ValidatedBodyTest.java b/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-jackson-json/src/test/java/dev/relism/flash/ext/jackson/json/ValidatedBodyTest.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-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/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} -- 2.54.0 From 2fbe65fcc50795564ef3408594b5a0eb3c4fc1a6 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 23 Sep 2026 13:31:28 +0000 Subject: [PATCH 3/3] feat(ext-openapi): document what the code already says MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every class-based route is documented now, annotated or not: a route without @ApiOperation used to be dropped with a warning, which made the document lie by omission. Read off the handler: the request body from the type it declares (or from the new @RequestBody, for one that reads the body itself), the success schema from the most specific handle it implements, and the media type from @Consumes. Failures are described too. Any 4xx or 5xx without an explicit schema documents the error object Flash actually answers with, written once under components.schemas.Error — before this, a declared 4xx inherited the success schema, which was simply wrong. And an answer two or more operations give identically is hoisted into components.responses and referenced, so the 401 of every guarded route appears once rather than on every path. @Content gained an example, and the schema registry moved into its own class. Co-Authored-By: Claude Opus 5 --- flash-extensions/flash-ext-openapi/README.md | 184 +++-- .../dev/relism/flash/ext/openapi/Content.java | 3 + .../flash/ext/openapi/OpenApiBuilder.java | 727 ++++++++---------- .../flash/ext/openapi/OpenApiExtension.java | 19 +- .../relism/flash/ext/openapi/RequestBody.java | 31 + .../dev/relism/flash/ext/openapi/Schemas.java | 247 ++++++ .../flash/ext/openapi/OpenApiBuilderTest.java | 144 ++++ 7 files changed, 821 insertions(+), 534 deletions(-) create mode 100644 flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/RequestBody.java create mode 100644 flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Schemas.java 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"); -- 2.54.0