From ef768a0f439f399469ac0f80d46d68a9d208fb0e Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 14:01:59 +0200 Subject: [PATCH] fix: Register text/html as default body mapper; resolve mapper by media type --- .../com/retailsvc/http/OpenApiServer.java | 1 + .../java/com/retailsvc/http/TypeMapper.java | 6 ++--- .../http/internal/ResponseRenderer.java | 3 ++- .../http/internal/TextTypeMapper.java | 7 +++--- .../com/retailsvc/http/ExtraHandlersIT.java | 23 +++++++++++++++++++ 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index a152662..16508b3 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -366,6 +366,7 @@ private static Map resolveBodyMappers( LinkedHashMap out = new LinkedHashMap<>(); out.put("application/x-www-form-urlencoded", new FormTypeMapper()); out.put("text/plain", new TextTypeMapper()); + out.put("text/html", new TextTypeMapper()); out.putAll(userSupplied); if (!out.containsKey(JSON)) { TypeMapper fallback = tryLoadGsonMapper(); diff --git a/src/main/java/com/retailsvc/http/TypeMapper.java b/src/main/java/com/retailsvc/http/TypeMapper.java index edf7e61..8af27d0 100644 --- a/src/main/java/com/retailsvc/http/TypeMapper.java +++ b/src/main/java/com/retailsvc/http/TypeMapper.java @@ -3,9 +3,9 @@ /** * Reads and writes request/response bodies for a specific media type. Registered on {@link * OpenApiServer.Builder#bodyMapper(String, TypeMapper)} keyed by media type. The library ships - * built-in mappers for {@code application/x-www-form-urlencoded} and {@code text/plain}; an {@code - * application/json} mapper must be supplied by the caller or auto-detected via Gson on the - * classpath. + * built-in mappers for {@code application/x-www-form-urlencoded}, {@code text/plain}, and {@code + * text/html}; an {@code application/json} mapper must be supplied by the caller or auto-detected + * via Gson on the classpath. */ public interface TypeMapper { diff --git a/src/main/java/com/retailsvc/http/internal/ResponseRenderer.java b/src/main/java/com/retailsvc/http/internal/ResponseRenderer.java index 30f61f5..204935f 100644 --- a/src/main/java/com/retailsvc/http/internal/ResponseRenderer.java +++ b/src/main/java/com/retailsvc/http/internal/ResponseRenderer.java @@ -77,7 +77,8 @@ private void renderBytes( } private byte[] serialize(Object body, String contentType) { - TypeMapper mapper = mappers.get(contentType.toLowerCase(Locale.ROOT)); + String mediaType = ContentTypeHeader.mediaType(contentType); + TypeMapper mapper = mappers.get(mediaType.toLowerCase(Locale.ROOT)); if (mapper == null) { throw new IllegalStateException("No TypeMapper registered for " + contentType); } diff --git a/src/main/java/com/retailsvc/http/internal/TextTypeMapper.java b/src/main/java/com/retailsvc/http/internal/TextTypeMapper.java index 0aec48b..a6b58d6 100644 --- a/src/main/java/com/retailsvc/http/internal/TextTypeMapper.java +++ b/src/main/java/com/retailsvc/http/internal/TextTypeMapper.java @@ -4,9 +4,10 @@ import java.nio.charset.StandardCharsets; /** - * Built-in {@link TypeMapper} for {@code text/plain}. Reads decode bytes using the charset declared - * on {@code Content-Type} (default UTF-8). Writes return {@code String.valueOf(value)} encoded as - * UTF-8. + * Built-in {@link TypeMapper} for textual media types (e.g. {@code text/plain}, {@code text/html}). + * Media-type-agnostic — only the {@code charset} parameter on the {@code Content-Type} header is + * read. Reads decode bytes using that charset (default UTF-8). Writes return {@code + * String.valueOf(value)} encoded as UTF-8. */ public final class TextTypeMapper implements TypeMapper { diff --git a/src/test/java/com/retailsvc/http/ExtraHandlersIT.java b/src/test/java/com/retailsvc/http/ExtraHandlersIT.java index c243531..f5e4dfa 100644 --- a/src/test/java/com/retailsvc/http/ExtraHandlersIT.java +++ b/src/test/java/com/retailsvc/http/ExtraHandlersIT.java @@ -57,6 +57,29 @@ void specHandlerServesClasspathResource() throws Exception { } } + @Test + void textHtmlResponseIsSerializedByDefaultMapper() throws Exception { + RequestHandler html = + req -> Response.of(200, "

hi

").withContentType("text/html; charset=UTF-8"); + + try (var s = + newBuilder().spec(spec).handlers(Map.of()).port(0).extraRoute("/page", html).build(); + var client = httpClient()) { + + var req = + HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + s.listenPort() + "/page")) + .GET() + .build(); + var resp = client.send(req, BodyHandlers.ofString()); + + assertThat(resp.statusCode()).isEqualTo(200); + assertThat(resp.headers().firstValue("Content-Type")) + .hasValueSatisfying(v -> assertThat(v).startsWith("text/html")); + assertThat(resp.body()).isEqualTo("

hi

"); + } + } + @Test void extraHandlerExceptionFlowsThroughExceptionHandler() throws Exception { RequestHandler boom =