feat: typed bodies, injected services, one Jackson module per format #20

Merged
Relism merged 3 commits from feature/handlers/typed-bodies-and-injected-services into master 2026-09-23 14:11:17 +00:00
14 changed files with 285 additions and 2 deletions
Showing only changes of commit ef4740f26d - Show all commits
@@ -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.
*
* <pre>{@code
* public final class ListUsers extends RequestHandler {
* @Inject private UserService users;
*
* @Override public Object handle(Request req, Response res) { return users.findAll(); }
* }
* }</pre>
*
* <p>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}).
*
* <h3>What it does, exactly</h3>
* <ul>
* <li>Filled inside {@code bind}, <b>before</b> {@code onInit}, once per handler instance when
* the route is registered. Never per request: the request path reads a field.</li>
* <li>Every annotated field of the handler and of its bases up to {@code RequestHandler} is
* filled, {@code private} included.</li>
* <li>The service is looked up by the field's <b>declared type, exactly</b> — not a supertype,
* not a generic parameter. A type nothing provides fails the boot, naming the field.</li>
* <li>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.</li>
* </ul>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Inject {}
@@ -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.
*
* <p>The type is the class's own type argument, so it is written once, in the signature:
*
* <pre>{@code
* @POST("/users")
* public final class CreateUser extends JsonHandler<NewUser> {
* @Override protected Object handle(Request req, Response res, NewUser body) {
* return users.create(body);
* }
* }
* }</pre>
*
* <p>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 <B> the body type
*/
public abstract class BodyHandler<B> extends RequestHandler {
/**
* The body type a handler class declares, or {@code null} when it leaves it open.
*
* <p>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<TypeVariable<?>, 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<TypeVariable<?>, 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<B> bodyType() {
return (Class<B>) 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));
}
}
@@ -2,8 +2,11 @@ package dev.relism.flash.models;
import dev.relism.flash.extension.FlashApp; import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.Inject;
import dev.relism.flash.routing.Route; import dev.relism.flash.routing.Route;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -22,8 +25,9 @@ import java.util.Optional;
* </ol> * </ol>
* *
* <h3>Service access</h3> * <h3>Service access</h3>
* Override {@link #onInit()} to cache services from the {@link FlashContext} * Annotate a field with {@link Inject} and it is filled at boot, or override {@link #onInit()}
* into private fields. This keeps the hot-path ({@code handle}) free of map lookups. * to cache services from the {@link FlashContext} by hand. Either way the hot path
* ({@code handle}) reads a field and never the context.
* *
* <pre>{@code * <pre>{@code
* @GET("/users") * @GET("/users")
@@ -53,9 +57,41 @@ public abstract class RequestHandler {
*/ */
public final void bind(FlashContext ctx) { public final void bind(FlashContext ctx) {
this.ctx = ctx; this.ctx = ctx;
inject();
onInit(); 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}, * Override to cache services at boot time. Called once after {@link #bind},
* before any request reaches this handler. * before any request reaches this handler.
@@ -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.
*
* <p>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();
}
@@ -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<B> extends BodyHandler<B> {
@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<Payload> {
@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<Object> {
@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));
}
}