preparing for another refactoring...
This commit is contained in:
+47
-42
@@ -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.)
|
||||
}
|
||||
}
|
||||
|
||||
-77
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user