From ef4740f26d0381f87657babd662dc69da5befb85 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 23 Sep 2026 13:31:13 +0000 Subject: [PATCH] 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)); + } +}