feat(core): a service a handler asks for, and a body typed in its signature

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<B> 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 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-23 13:31:13 +00:00
co-authored by Claude Opus 5
parent 8353033cb1
commit ef4740f26d
14 changed files with 285 additions and 2 deletions
@@ -1,62 +0,0 @@
package dev.relism.flash.ext.jackson;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JacksonExtensionTest {
@Test
void configure_registers_json_mapper_and_middleware() {
FlashContext ctx = new FlashContext();
ObjectMapper mapper = new ObjectMapper();
JacksonExtension ext = new JacksonExtension(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 {
ObjectMapper mapper = new ObjectMapper();
JacksonExtension ext = new JacksonExtension(mapper);
RequestHandler next = new RequestHandler() {
@Override
public Object handle(Request request, Response response) {
return new Payload("ok");
}
};
RequestHandler wrapped = new RequestHandler() {
private final SimpleHandler.FunctionalHandler delegate = ext.autoJson().wrap(next);
@Override
public Object handle(Request request, Response response) throws Exception {
return delegate.handle(request, response);
}
};
Response res = new Response(200, ContentType.TEXT_PLAIN);
Object out = wrapped.handle(null, res);
assertTrue(out instanceof byte[]);
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
}
private record Payload(String status) {}
}
@@ -1,90 +0,0 @@
package dev.relism.flash.ext.jackson;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
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.assertInstanceOf;
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 {
private static final Request REQ = null;
@Test
void autoJson_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
RequestHandler wrapped = wrap(mw, new UserDto("u1", "alice"));
Response res = new Response(200, ContentType.TEXT_PLAIN);
Object out = wrapped.handle(REQ, res);
assertInstanceOf(byte[].class, out);
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
assertEquals("{\"id\":\"u1\",\"name\":\"alice\"}", new String((byte[]) out, StandardCharsets.UTF_8));
}
@Test
void autoJson_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
Response payloadResponse = new Response(201, ContentType.TEXT_PLAIN).body("ok");
RequestHandler wrappedResponse = wrap(mw, payloadResponse);
Response res = new Response(200, ContentType.TEXT_PLAIN);
assertSame(payloadResponse, wrappedResponse.handle(REQ, res));
String s = "hello";
assertSame(s, wrap(mw, s).handle(REQ, res));
CharSequence cs = new StringBuilder("hello-cs");
assertSame(cs, wrap(mw, cs).handle(REQ, res));
byte[] bytes = new byte[]{1, 2, 3};
assertSame(bytes, wrap(mw, bytes).handle(REQ, res));
assertSame(null, wrap(mw, null).handle(REQ, res));
}
@Test
void autoJson_wraps_serialization_errors_as_illegal_state() {
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
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:"));
}
private static RequestHandler wrap(JacksonMiddleware mw, Object fixedReturn) {
RequestHandler next = new RequestHandler() {
@Override
public Object handle(Request request, Response response) {
return fixedReturn;
}
};
return new RequestHandler() {
private final SimpleHandler.FunctionalHandler delegate = mw.autoJson().wrap(next);
@Override
public Object handle(Request request, Response response) throws Exception {
return delegate.handle(request, response);
}
};
}
private record UserDto(String id, String name) {}
private static final class CyclicDto {
CyclicDto self = this;
}
}
@@ -1,116 +0,0 @@
package dev.relism.flash.ext.jackson;
import com.fasterxml.jackson.annotation.JsonView;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.exceptions.HttpException;
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JsonTest {
@Test
void body_parses_valid_json_and_maps_bad_payload_to_http_400() throws Exception {
Json json = new Json(new ObjectMapper());
Request ok = request("{\"id\":\"u1\",\"name\":\"alice\"}");
UserDto dto = json.body(ok, UserDto.class);
assertEquals("u1", dto.id);
assertEquals("alice", dto.name);
Request bad = request("not-json");
HttpException ex = assertThrows(HttpException.class, () -> json.body(bad, UserDto.class));
assertEquals(400, ex.status());
assertTrue(ex.getMessage().startsWith("Invalid request body:"));
}
@Test
void bodyFrom_parses_stream_and_maps_bad_payload_to_http_400() 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);
Request bad = request("[");
HttpException ex = assertThrows(HttpException.class, () -> json.bodyFrom(bad, UserDto.class));
assertEquals(400, ex.status());
}
@Test
void write_and_writeView_set_content_type_and_render_expected_payload() throws Exception {
Json json = new Json(new ObjectMapper());
Response res = new Response(200, ContentType.TEXT_PLAIN);
String payload = json.write(res, new UserDto("u3", "carol"));
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
assertEquals("{\"id\":\"u3\",\"name\":\"carol\"}", payload);
Response viewRes = new Response(200, ContentType.TEXT_PLAIN);
String viewed = json.writeView(viewRes, new ViewDto("u4", "hidden"), PublicView.class);
assertEquals("application/json", new String(viewRes.getContentType(), StandardCharsets.UTF_8));
assertEquals("{\"id\":\"u4\"}", viewed);
}
@Test
void mapper_returns_underlying_object_mapper_instance() {
ObjectMapper mapper = new ObjectMapper();
Json json = new Json(mapper);
assertSame(mapper, json.mapper());
}
private static Request request(String body) {
return request(body.getBytes(StandardCharsets.UTF_8));
}
private static Request request(byte[] body) {
RequestLine line = new RequestLine(
HttpMethod.POST,
new FastPathViews.StringByteView("/json"),
null,
new FastPathViews.StringByteView("HTTP/1.1"),
new Http1HeaderMap()
);
return new Request(line, body);
}
private static final class UserDto {
public String id;
public String name;
public UserDto() {}
private UserDto(String id, String name) {
this.id = id;
this.name = name;
}
}
private interface PublicView {}
private interface InternalView {}
private static final class ViewDto {
@JsonView(PublicView.class)
public String id;
@JsonView(InternalView.class)
public String secret;
private ViewDto(String id, String secret) {
this.id = id;
this.secret = secret;
}
}
}