pre-major refactoring + ext api.

This commit is contained in:
Relism
2026-03-26 14:10:53 +01:00
parent f8c86e315f
commit 7b996b552b
57 changed files with 3823 additions and 117 deletions
@@ -0,0 +1,72 @@
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.FlashApp;
import dev.relism.extension.FlashExtension;
import dev.relism.http.ContentType;
/**
* Registers Jackson into the 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>
*
* <pre>{@code
* FlashApp.of(new HttpServer(config))
* .install(new JacksonExtension());
*
* // Custom mapper:
* ObjectMapper mapper = JsonMapper.builder()
* .addModule(new JavaTimeModule())
* .build();
* .install(new JacksonExtension(mapper));
* }</pre>
*/
public class JacksonExtension implements FlashExtension {
private final ObjectMapper mapper;
public JacksonExtension() {
this(JsonMapper.builder().build());
}
public JacksonExtension(ObjectMapper mapper) {
this.mapper = mapper;
}
@Override
public void install(FlashApp 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");
}
}
@@ -0,0 +1,77 @@
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");
}
}