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:
co-authored by
Claude Opus 5
parent
8353033cb1
commit
ef4740f26d
+68
@@ -0,0 +1,68 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
|
||||
import dev.relism.flash.ext.jackson.JacksonExtension;
|
||||
import dev.relism.flash.ext.openapi.APIResponse;
|
||||
import dev.relism.flash.ext.openapi.ApiOperation;
|
||||
import dev.relism.flash.ext.openapi.Content;
|
||||
import dev.relism.flash.ext.openapi.OpenApiExtension;
|
||||
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 dev.relism.flash.routing.GET;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
/**
|
||||
* Constraints are declared once and read twice: the validator enforces them, the published schema
|
||||
* describes them. Nothing registers this bridge — flash-ext-openapi picks the annotations up on
|
||||
* its own when they are on the classpath.
|
||||
*/
|
||||
class ValidationOpenApiInteropTest {
|
||||
|
||||
record Account(
|
||||
@NotBlank @Size(max = 40) String name,
|
||||
@Email String email,
|
||||
@Min(18) @Max(120) int age) {}
|
||||
|
||||
@GET("/accounts")
|
||||
@ApiOperation(summary = "List accounts")
|
||||
@APIResponse(responseCode = "200", content = @Content(contentType = ContentType.JSON, schema = Account.class))
|
||||
public static class ListAccounts extends RequestHandler {
|
||||
@Override public Object handle(Request request, Response response) {
|
||||
return new Account("alice", "a@b.com", 30);
|
||||
}
|
||||
}
|
||||
|
||||
@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");
|
||||
});
|
||||
|
||||
@Test
|
||||
void constraintsAppearInTheGeneratedSchema() {
|
||||
app.get("/openapi.json")
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"maxLength\":40")
|
||||
.expectBodyContains("\"format\":\"email\"")
|
||||
.expectBodyContains("\"minimum\":18")
|
||||
.expectBodyContains("\"maximum\":120");
|
||||
}
|
||||
|
||||
@Test
|
||||
void notBlankMarksThePropertyRequiredAndNonEmpty() {
|
||||
app.get("/openapi.json")
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"minLength\":1")
|
||||
.expectBodyContains("\"required\":[\"name\"]");
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
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) {}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
|
||||
import dev.relism.flash.ext.jackson.JacksonExtension;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.junit.jupiter.api.Test;
|
||||
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 {
|
||||
|
||||
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.ctx().onReady(() -> {
|
||||
Validation validation = configured.ctx().require(Validation.class);
|
||||
configured.post("/users", (req, res) ->
|
||||
res.status(201).body("created:" + validation.body(req, CreateUser.class).name()));
|
||||
});
|
||||
});
|
||||
|
||||
@Test
|
||||
void validBodyReachesTheHandler() {
|
||||
app.request().json("{\"name\":\"alice\",\"email\":\"a@b.com\",\"age\":30}").post("/users")
|
||||
.expectStatus(201)
|
||||
.expectBody("created:alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void constraintViolationBecomes422WithEveryFailureListed() {
|
||||
app.request().json("{\"name\":\"\",\"email\":\"nope\",\"age\":5}").post("/users")
|
||||
.expectStatus(422)
|
||||
.expectHeader("Content-Type", "application/json")
|
||||
.expectBodyContains("name must not be blank")
|
||||
.expectBodyContains("email must be a well-formed email address")
|
||||
.expectBodyContains("age must be at least 18");
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedJsonBecomes400NotAValidationFailure() {
|
||||
app.request().json("not json").post("/users")
|
||||
.expectStatus(400)
|
||||
.expectBodyContains("Invalid request body");
|
||||
}
|
||||
|
||||
/** Regression guard: HttpException used to reach the catch-all and come back as 500. */
|
||||
@Test
|
||||
void statusCarriedByTheExceptionSurvivesToTheWire() {
|
||||
assertEquals(422, app.request().json("{\"name\":\"x\",\"email\":\"a@b.com\",\"age\":1}")
|
||||
.post("/users").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void errorBodyIsValidJsonEvenWhenTheMessageContainsQuotes() {
|
||||
app.request().json("{\"name\":\"waaaaaaaaaay-too-long\",\"email\":\"a@b.com\",\"age\":30}").post("/users")
|
||||
.expectStatus(422)
|
||||
.expectBodyContains("\"status\":422")
|
||||
.expectBodyContains("size must be at most 8");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user