feat: typed bodies, injected services, one Jackson module per format #20
@@ -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.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;
|
||||
* </ol>
|
||||
*
|
||||
* <h3>Service access</h3>
|
||||
* 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.
|
||||
*
|
||||
* <pre>{@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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user