preparing for another refactoring...

This commit is contained in:
Relism
2026-03-29 23:16:41 +02:00
parent 2edd68b0aa
commit b5d4481502
69 changed files with 4329 additions and 1076 deletions
@@ -2,71 +2,76 @@ package dev.relism.ext.jackson;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import dev.relism.exceptions.HttpException;
import dev.relism.extension.ExtensionContext;
import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashExtension;
import dev.relism.extension.FlashRegistrar;
import dev.relism.http.ContentType;
/**
* Registers Jackson into the extension layer.
* Registers JSON support into the Flash extension layer.
*
* <p>What this installs:
* <ul>
* <li>Exposes the {@link ObjectMapper} in {@link ExtensionContext} — consumed by
* {@code flash-ext-openapi} and any extension that needs JSON serialization.</li>
* <li>Sets a global exception handler that maps {@link HttpException} to a JSON
* error body and catches all other exceptions as 500.</li>
* <li>Injects the mapper into {@link JacksonHandler} so all subclasses gain
* {@code bodyAs} and {@code json} without constructor boilerplate.</li>
* </ul>
* <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under
* {@code Json.class}. Any handler or extension in the same scope can retrieve
* it via {@code require(Json.class)} inside {@code onInit()}.
*
* <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
*
* <h3>Usage — composition (preferred)</h3>
* <pre>{@code
* FlashApp.of(new HttpServer(config))
* .install(new JacksonExtension());
* // No mandatory base class. Works from any RequestHandler.
* public class MyHandler extends RequestHandler {
* private Json json;
*
* // Custom mapper:
* @Override protected void onInit() {
* json = require(Json.class);
* }
*
* public Object handle(Request req, Response res) throws Exception {
* MyDto dto = json.body(req, MyDto.class);
* return json.write(res, 201, dto);
* }
* }
* }</pre>
*
* <h3>Usage — convenience base class</h3>
* <pre>{@code
* // JacksonHandler remains available as a thin opt-in wrapper.
* public class MyHandler extends JacksonHandler {
* public Object handle(Request req, Response res) throws Exception {
* return json(res, service.findAll());
* }
* }
* }</pre>
*
* <h3>Custom mapper</h3>
* <pre>{@code
* ObjectMapper mapper = JsonMapper.builder()
* .addModule(new JavaTimeModule())
* .build();
* .install(new JacksonExtension(mapper));
* .addModule(new JavaTimeModule())
* .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
* .build();
*
* FlashApp.create(8080)
* .install(new JacksonExtension(mapper));
* }</pre>
*/
public class JacksonExtension implements FlashExtension {
private final ObjectMapper mapper;
/** Installs with a default {@link JsonMapper} (no extra modules). */
public JacksonExtension() {
this(JsonMapper.builder().build());
}
/** Installs with a fully configured custom {@link ObjectMapper}. */
public JacksonExtension(ObjectMapper mapper) {
this.mapper = mapper;
}
@Override
public void install(FlashRegistrar app, ExtensionContext ctx) {
ctx.provide(ObjectMapper.class, mapper);
JacksonHandler.mapper = mapper;
app.onException((ex, req, res) -> {
if (ex instanceof HttpException e) {
res.setStatusCode(e.status());
res.setContentType(ContentType.JSON);
return "{\"error\":\"" + escapeJson(e.getMessage()) + "\"}";
}
res.setStatusCode(500);
res.setContentType(ContentType.JSON);
return "{\"error\":\"Internal Server Error\"}";
});
}
private static String escapeJson(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
public void install(FlashRegistrar app, FlashContext ctx) {
Json json = new Json(mapper);
ctx.provide(Json.class, json);
ctx.provide(ObjectMapper.class, mapper); // backward compat for extensions (OpenAPI, etc.)
}
}
@@ -1,77 +0,0 @@
package dev.relism.ext.jackson;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.exceptions.HttpException;
import dev.relism.http.ContentType;
import dev.relism.models.Request;
import dev.relism.models.RequestHandler;
import dev.relism.models.Response;
/**
* Base class for handlers that need JSON I/O via Jackson.
*
* <p>The {@link ObjectMapper} is injected by {@link JacksonExtension#install} once at
* startup — all subclasses share the same instance. If no {@code JacksonExtension} is
* installed the field remains {@code null} and the first call to {@link #bodyAs} or
* {@link #json} will throw an {@link IllegalStateException}.
*
* <pre>{@code
* @Route(method = HttpMethod.POST, path = "/api/blogs")
* public class CreateBlog extends JacksonHandler {
* public Object handle(Request req, Response res) throws Exception {
* CreateBlogRequest body = bodyAs(req, CreateBlogRequest.class);
* Blog created = service.create(body);
* res.setStatusCode(201);
* return json(res, created);
* }
* }
* }</pre>
*/
public abstract class JacksonHandler extends RequestHandler {
/**
* Shared mapper set by {@link JacksonExtension}. Package-visible so the extension
* can assign it; {@code volatile} ensures visibility across virtual threads.
*/
static volatile ObjectMapper mapper;
/**
* Deserializes the request body bytes into {@code type}.
* Wraps Jackson parse errors as {@link HttpException} 400.
*/
protected <T> T bodyAs(Request req, Class<T> type) throws Exception {
requireMapper();
try {
return mapper.readValue(req.body().bytes(), type);
} catch (JsonProcessingException e) {
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
}
}
/**
* Serializes {@code obj} to JSON, sets {@code Content-Type: application/json},
* and returns the JSON string as the response body.
*/
protected String json(Response res, Object obj) throws Exception {
requireMapper();
res.setContentType(ContentType.JSON);
return mapper.writeValueAsString(obj);
}
/**
* Like {@link #json} but serializes only fields visible under the given
* {@code view} class (see Jackson {@code @JsonView}).
*/
protected String jsonView(Response res, Object obj, Class<?> view) throws Exception {
requireMapper();
res.setContentType(ContentType.JSON);
return mapper.writerWithView(view).writeValueAsString(obj);
}
private static void requireMapper() {
if (mapper == null)
throw new IllegalStateException(
"JacksonExtension not installed: call FlashApp.install(new JacksonExtension()) first");
}
}
@@ -0,0 +1,126 @@
package dev.relism.ext.jackson;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.exceptions.HttpException;
import dev.relism.http.ContentType;
import dev.relism.models.Request;
import dev.relism.models.Response;
/**
* Thread-safe JSON toolbox. Single point of access for all JSON I/O operations
* within a Flash application.
*
* <p>Retrieve once at boot time via {@code require(Json.class)} inside
* {@code onInit()}, cache in a private field, and call on the hot path
* with zero lookup or allocation overhead:
*
* <pre>{@code
* @Route(method = HttpMethod.POST, path = "/api/items")
* public class CreateItemHandler extends RequestHandler {
*
* private Json json;
*
* @Override
* protected void onInit() {
* json = require(Json.class);
* }
*
* public Object handle(Request req, Response res) throws Exception {
* CreateItemRequest body = json.body(req, CreateItemRequest.class);
* return json.write(res, itemService.create(body));
* }
* }
* }</pre>
*
* <p>The underlying {@link ObjectMapper} is shared across all handlers in the same
* scope (one instance per app / per child scope). Jackson's {@code ObjectMapper}
* is fully thread-safe after configuration — no synchronization is needed.
*
* <p>Install via {@link JacksonExtension} before calling {@code scan()} or
* {@code register()}.
*/
public final class Json {
private final ObjectMapper mapper;
/** Package-private — constructed exclusively by {@link JacksonExtension}. */
Json(ObjectMapper mapper) {
this.mapper = mapper;
}
// ── Input ─────────────────────────────────────────────────────────────────
/**
* Deserializes the full request body into an instance of {@code type}.
*
* <p>Reads {@code req.body().bytes()} in one shot. For streaming bodies
* use {@link #bodyFrom(Request, Class)} instead.
*
* @throws HttpException 400 if the body cannot be parsed as {@code type}
*/
public <T> T body(Request req, Class<T> type) throws Exception {
try {
return mapper.readValue(req.body().bytes(), type);
} catch (JsonProcessingException e) {
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
}
}
/**
* Deserializes the request body via the raw {@link java.io.InputStream},
* avoiding the intermediate {@code byte[]} allocation. Prefer this for
* large bodies or when allocation budget is tight.
*
* @throws HttpException 400 on parse failure
*/
public <T> T bodyFrom(Request req, Class<T> type) throws Exception {
try {
return mapper.readValue(req.body().stream(), type);
} catch (JsonProcessingException e) {
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
}
}
// ── Output ────────────────────────────────────────────────────────────────
/**
* Serializes {@code obj} to a JSON string and sets
* {@code Content-Type: application/json} on the response.
*
* <p>The returned string is used as the response body by the Flash runtime.
*/
public String write(Response res, Object obj) throws Exception {
res.setContentType(ContentType.JSON);
return mapper.writeValueAsString(obj);
}
/**
* Like {@link #write(Response, Object)} but also sets an explicit HTTP status code.
*/
public String write(Response res, int status, Object obj) throws Exception {
res.status(status);
res.setContentType(ContentType.JSON);
return mapper.writeValueAsString(obj);
}
/**
* Like {@link #write} but applies a Jackson {@code @JsonView} filter,
* restricting serialization to fields visible under {@code view}.
*/
public String writeView(Response res, Object obj, Class<?> view) throws Exception {
res.setContentType(ContentType.JSON);
return mapper.writerWithView(view).writeValueAsString(obj);
}
// ── Escape hatch ──────────────────────────────────────────────────────────
/**
* Returns the underlying {@link ObjectMapper} for advanced operations
* (custom serialization, schema generation, etc.) not covered by the
* methods above.
*/
public ObjectMapper mapper() {
return mapper;
}
}