diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index 8ca9abd..4e2ceda 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -11,6 +11,7 @@ import com.retailsvc.http.internal.HealthRenderer; import com.retailsvc.http.internal.ProblemDetail; +import com.retailsvc.http.internal.ProblemDetailRenderer; import com.retailsvc.http.internal.ResourceSource; import java.io.InputStream; import java.nio.file.Path; @@ -27,19 +28,18 @@ public final class Handlers { private Handlers() {} - public static ExceptionHandler defaultExceptionHandler(TypeMapper jsonMapper) { - Objects.requireNonNull(jsonMapper, "jsonMapper must not be null"); + public static ExceptionHandler defaultExceptionHandler() { return t -> switch (t) { case ValidationException ve -> Response.bytes( HTTP_BAD_REQUEST, - jsonMapper.writeTo(ProblemDetail.forValidation(ve.error())), + ProblemDetailRenderer.renderJson(ProblemDetail.forValidation(ve.error())), "application/problem+json"); case BadRequestException bre -> Response.bytes( bre.status(), - jsonMapper.writeTo(ProblemDetail.forBadRequest(bre)), + ProblemDetailRenderer.renderJson(ProblemDetail.forBadRequest(bre)), "application/problem+json"); case NotFoundException _ -> Response.notFound(); case MethodNotAllowedException mna -> diff --git a/src/main/java/com/retailsvc/http/OpenApiServer.java b/src/main/java/com/retailsvc/http/OpenApiServer.java index 12371ef..46352d2 100644 --- a/src/main/java/com/retailsvc/http/OpenApiServer.java +++ b/src/main/java/com/retailsvc/http/OpenApiServer.java @@ -14,6 +14,7 @@ import com.retailsvc.http.internal.Router; import com.retailsvc.http.internal.SecurityFilter; import com.retailsvc.http.internal.TextTypeMapper; +import com.retailsvc.http.internal.gson.GsonJsonMapper; import com.retailsvc.http.spec.Operation; import com.retailsvc.http.spec.Spec; import com.retailsvc.http.spec.security.SecurityRequirement; @@ -47,7 +48,6 @@ public class OpenApiServer implements AutoCloseable { private static final int DEFAULT_PORT = 8080; private static final String JSON = "application/json"; private static final String GSON_CLASS = "com.google.gson.Gson"; - private static final String GSON_MAPPER_CLASS = "com.retailsvc.http.internal.gson.GsonJsonMapper"; private final HttpServer httpServer; private final int shutdownTimeoutSeconds; @@ -112,8 +112,7 @@ record HandlerConfig( spec.securitySchemes(), spec.security(), handlerConfig.securityValidators(), - handlerConfig.externalAuth(), - bodyMappers.get(JSON))); + handlerConfig.externalAuth())); ctx.setHandler( new DispatchHandler( handlerConfig.handlers(), @@ -342,9 +341,7 @@ public OpenApiServer build() throws IOException { } Map resolved = resolveBodyMappers(bodyMappers); ExceptionHandler effectiveExceptionHandler = - exceptionHandler != null - ? exceptionHandler - : Handlers.defaultExceptionHandler(resolved.get(JSON)); + exceptionHandler != null ? exceptionHandler : Handlers.defaultExceptionHandler(); HandlerConfig handlerConfig = new HandlerConfig( handlers, @@ -410,12 +407,7 @@ private static TypeMapper tryLoadGsonMapper() { } catch (ClassNotFoundException _) { return null; } - try { - Class cls = Class.forName(GSON_MAPPER_CLASS, true, OpenApiServer.class.getClassLoader()); - return (TypeMapper) cls.getDeclaredConstructor().newInstance(); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Failed to load " + GSON_MAPPER_CLASS, e); - } + return new GsonJsonMapper(); } } } diff --git a/src/main/java/com/retailsvc/http/internal/HealthRenderer.java b/src/main/java/com/retailsvc/http/internal/HealthRenderer.java index cec7e45..e59c218 100644 --- a/src/main/java/com/retailsvc/http/internal/HealthRenderer.java +++ b/src/main/java/com/retailsvc/http/internal/HealthRenderer.java @@ -23,36 +23,12 @@ public static String renderJson(boolean up, List dependencies) { } Dependency d = dependencies.get(i); sb.append("{\"id\":"); - appendJsonString(sb, d.id()); + JsonStrings.appendQuoted(sb, d.id()); sb.append(",\"status\":\"").append(label(d.up())).append("\"}"); } return sb.append("]}").toString(); } - private static void appendJsonString(StringBuilder sb, String s) { - sb.append('"'); - for (int i = 0; i < s.length(); i++) { - char c = s.charAt(i); - switch (c) { - case '"' -> sb.append("\\\""); - case '\\' -> sb.append("\\\\"); - case '\b' -> sb.append("\\b"); - case '\f' -> sb.append("\\f"); - case '\n' -> sb.append("\\n"); - case '\r' -> sb.append("\\r"); - case '\t' -> sb.append("\\t"); - default -> { - if (c < 0x20) { - sb.append(String.format("\\u%04x", (int) c)); - } else { - sb.append(c); - } - } - } - } - sb.append('"'); - } - private static String label(boolean up) { return up ? "Up" : "Down"; } diff --git a/src/main/java/com/retailsvc/http/internal/JsonStrings.java b/src/main/java/com/retailsvc/http/internal/JsonStrings.java new file mode 100644 index 0000000..079cff8 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/JsonStrings.java @@ -0,0 +1,40 @@ +package com.retailsvc.http.internal; + +/** + * Minimal JSON string-escape helper shared by the library's hand-rolled JSON writers ({@link + * HealthRenderer}, {@link ProblemDetailRenderer}). Lets those renderers emit RFC 8259 compliant + * strings without pulling in a JSON library and without record-accessor reflection that GraalVM + * Native Image would otherwise need configured. + */ +final class JsonStrings { + + /** Codepoints below this value are control characters and must be unicode-escaped. */ + private static final int FIRST_PRINTABLE_ASCII = 0x20; + + private JsonStrings() {} + + /** Appends {@code value} surrounded by double quotes, with JSON string-escaping applied. */ + static void appendQuoted(StringBuilder out, String value) { + out.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\b' -> out.append("\\b"); + case '\f' -> out.append("\\f"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + default -> { + if (c < FIRST_PRINTABLE_ASCII) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + } + } + out.append('"'); + } +} diff --git a/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java b/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java new file mode 100644 index 0000000..4105c09 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java @@ -0,0 +1,54 @@ +package com.retailsvc.http.internal; + +import java.nio.charset.StandardCharsets; + +/** + * Built-in JSON writer for the {@code application/problem+json} (RFC 7807) wire shape. Keeps the + * exception and security paths free of any {@code TypeMapper}, so the library can emit problem + * responses without a JSON library on the classpath (and without record-accessor reflection that + * GraalVM Native Image would otherwise need configured). + * + *

Null-valued fields are omitted, matching the default Gson encoding the library historically + * produced. + */ +public final class ProblemDetailRenderer { + + /** Initial capacity sized for a typical problem-detail document. */ + private static final int INITIAL_CAPACITY = 128; + + private ProblemDetailRenderer() {} + + public static byte[] renderJson(ProblemDetail pd) { + StringBuilder out = new StringBuilder(INITIAL_CAPACITY); + out.append('{'); + boolean first = true; + first = appendString(out, first, "type", pd.type()); + first = appendString(out, first, "title", pd.title()); + first = appendInt(out, first, "status", pd.status()); + first = appendString(out, first, "detail", pd.detail()); + first = appendString(out, first, "pointer", pd.pointer()); + appendString(out, first, "keyword", pd.keyword()); + out.append('}'); + return out.toString().getBytes(StandardCharsets.UTF_8); + } + + private static boolean appendString(StringBuilder out, boolean first, String name, String value) { + if (value == null) { + return first; + } + if (!first) { + out.append(','); + } + out.append('"').append(name).append("\":"); + JsonStrings.appendQuoted(out, value); + return false; + } + + private static boolean appendInt(StringBuilder out, boolean first, String name, int value) { + if (!first) { + out.append(','); + } + out.append('"').append(name).append("\":").append(value); + return false; + } +} diff --git a/src/main/java/com/retailsvc/http/internal/SecurityFilter.java b/src/main/java/com/retailsvc/http/internal/SecurityFilter.java index 22bd1d7..b196e48 100644 --- a/src/main/java/com/retailsvc/http/internal/SecurityFilter.java +++ b/src/main/java/com/retailsvc/http/internal/SecurityFilter.java @@ -5,7 +5,6 @@ import com.retailsvc.http.Request; import com.retailsvc.http.SchemeValidator; -import com.retailsvc.http.TypeMapper; import com.retailsvc.http.spec.Operation; import com.retailsvc.http.spec.security.SecurityRequirement; import com.retailsvc.http.spec.security.SecurityScheme; @@ -19,7 +18,6 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Objects; import java.util.Optional; public final class SecurityFilter extends Filter { @@ -29,21 +27,18 @@ public final class SecurityFilter extends Filter { private final List rootSecurity; private final Map validators; private final boolean externalAuth; - private final TypeMapper jsonMapper; public SecurityFilter( Map operationsById, Map schemes, List rootSecurity, Map validators, - boolean externalAuth, - TypeMapper jsonMapper) { + boolean externalAuth) { this.operationsById = Map.copyOf(operationsById); this.schemes = Map.copyOf(schemes); this.rootSecurity = List.copyOf(rootSecurity); this.validators = Map.copyOf(validators); this.externalAuth = externalAuth; - this.jsonMapper = Objects.requireNonNull(jsonMapper, "jsonMapper must not be null"); } @Override @@ -125,7 +120,7 @@ private void renderRejection(HttpExchange exchange, List fa ProblemDetail problemDetail = new ProblemDetail("about:blank", title, status, detail, null, null); - byte[] body = jsonMapper.writeTo(problemDetail); + byte[] body = ProblemDetailRenderer.renderJson(problemDetail); exchange.getResponseHeaders().add("Content-Type", "application/problem+json"); if (!anyDenied) { LinkedHashSet attempted = new LinkedHashSet<>(); diff --git a/src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java b/src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java index c371361..36d0fdd 100644 --- a/src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java +++ b/src/test/java/com/retailsvc/http/HandlersDefaultExceptionTest.java @@ -16,7 +16,7 @@ class HandlersDefaultExceptionTest { @Test void validationExceptionRendersProblemJson() { Response resp = - Handlers.defaultExceptionHandler(JSON) + Handlers.defaultExceptionHandler() .handle( new ValidationException( new ValidationError("/x", "type", "expected string", null))); @@ -35,7 +35,7 @@ void validationExceptionRendersProblemJson() { @Test void badRequestExceptionRendersProblemJsonWithCustomStatus() { Response resp = - Handlers.defaultExceptionHandler(JSON) + Handlers.defaultExceptionHandler() .handle(new BadRequestException(422, "email taken", "/email", "unique")); assertThat(resp.status()).isEqualTo(422); @@ -53,7 +53,7 @@ void badRequestExceptionRendersProblemJsonWithCustomStatus() { @Test void notFoundReturns404() { - Response resp = Handlers.defaultExceptionHandler(JSON).handle(new NotFoundException("GET /x")); + Response resp = Handlers.defaultExceptionHandler().handle(new NotFoundException("GET /x")); assertThat(resp.status()).isEqualTo(404); assertThat(resp.body()).isNull(); @@ -62,7 +62,7 @@ void notFoundReturns404() { @Test void methodNotAllowedReturns405WithAllowHeader() { Response resp = - Handlers.defaultExceptionHandler(JSON) + Handlers.defaultExceptionHandler() .handle(new MethodNotAllowedException(Set.of(HttpMethod.GET, HttpMethod.POST))); assertThat(resp.status()).isEqualTo(405); @@ -72,7 +72,7 @@ void methodNotAllowedReturns405WithAllowHeader() { @Test void unknownExceptionReturns500() { - Response resp = Handlers.defaultExceptionHandler(JSON).handle(new RuntimeException("kaboom")); + Response resp = Handlers.defaultExceptionHandler().handle(new RuntimeException("kaboom")); assertThat(resp.status()).isEqualTo(500); assertThat(resp.body()).isNull(); diff --git a/src/test/java/com/retailsvc/http/internal/ProblemDetailRendererTest.java b/src/test/java/com/retailsvc/http/internal/ProblemDetailRendererTest.java new file mode 100644 index 0000000..580f3d8 --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/ProblemDetailRendererTest.java @@ -0,0 +1,77 @@ +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class ProblemDetailRendererTest { + + @Test + void rendersAllFieldsWhenPresent() { + ProblemDetail pd = + new ProblemDetail("about:blank", "Bad Request", 400, "expected string", "/x", "type"); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo( + "{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400," + + "\"detail\":\"expected string\",\"pointer\":\"/x\",\"keyword\":\"type\"}"); + } + + @Test + void omitsNullPointerAndKeyword() { + ProblemDetail pd = + new ProblemDetail("about:blank", "Unauthorized", 401, "missing token", null, null); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo( + "{\"type\":\"about:blank\",\"title\":\"Unauthorized\",\"status\":401," + + "\"detail\":\"missing token\"}"); + } + + @Test + void omitsNullDetail() { + ProblemDetail pd = new ProblemDetail("about:blank", "Not Found", 404, null, null, null); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo("{\"type\":\"about:blank\",\"title\":\"Not Found\",\"status\":404}"); + } + + @Test + void escapesQuoteAndBackslashInDetail() { + ProblemDetail pd = new ProblemDetail("about:blank", "Bad Request", 400, "a\"b\\c", null, null); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo( + "{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400," + + "\"detail\":\"a\\\"b\\\\c\"}"); + } + + @Test + void escapesNamedControlCharsInDetail() { + ProblemDetail pd = + new ProblemDetail("about:blank", "Bad Request", 400, "\b\f\n\r\t", null, null); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo( + "{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400," + + "\"detail\":\"\\b\\f\\n\\r\\t\"}"); + } + + @Test + void escapesUnnamedControlCharsAsHexUnicode() { + ProblemDetail pd = new ProblemDetail("about:blank", "Bad Request", 400, "", null, null); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo( + "{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400," + + "\"detail\":\"\\u0001\\u001f\"}"); + } + + @Test + void passesThroughNonAsciiCharactersVerbatim() { + ProblemDetail pd = new ProblemDetail("about:blank", "Bad Request", 400, "café-é", null, null); + assertThat(asString(ProblemDetailRenderer.renderJson(pd))) + .isEqualTo( + "{\"type\":\"about:blank\",\"title\":\"Bad" + + " Request\",\"status\":400,\"detail\":\"café-é\"}"); + } + + private static String asString(byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java b/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java index 3b76f3e..db83b2a 100644 --- a/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java +++ b/src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java @@ -11,7 +11,6 @@ import com.retailsvc.http.Request; import com.retailsvc.http.SchemeValidator; -import com.retailsvc.http.TypeMapper; import com.retailsvc.http.spec.HttpMethod; import com.retailsvc.http.spec.Operation; import com.retailsvc.http.spec.security.SecurityRequirement; @@ -24,7 +23,6 @@ import com.sun.net.httpserver.HttpExchange; import java.io.ByteArrayOutputStream; import java.net.URI; -import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.Optional; @@ -51,8 +49,7 @@ void allowsRequestWhenValidatorReturnsPrincipal() throws Exception { Map.of("bearerAuth", (req, cred) -> Optional.of("user-1")); SecurityFilter filter = - new SecurityFilter( - Map.of("getX", op), schemes, List.of(), validators, false, mockJsonMapper()); + new SecurityFilter(Map.of("getX", op), schemes, List.of(), validators, false); HttpExchange ex = mock(HttpExchange.class); Headers headers = new Headers(); @@ -91,8 +88,7 @@ void passesThroughWhenOperationHasNoSecurity() throws Exception { Optional.empty()); // inherits root, root is empty SecurityFilter filter = - new SecurityFilter( - Map.of("getY", op), Map.of(), List.of(), Map.of(), false, mockJsonMapper()); + new SecurityFilter(Map.of("getY", op), Map.of(), List.of(), Map.of(), false); HttpExchange ex = mock(HttpExchange.class); Chain chain = mock(Chain.class); @@ -121,8 +117,7 @@ void missingCredentialReturns401WithBearerChallenge() throws Exception { Map.of("bearerAuth", new HttpBearer(Optional.empty())), List.of(), Map.of("bearerAuth", (req, cred) -> Optional.of("never-called")), - false, - mockJsonMapper()); + false); HttpExchange ex = mock(HttpExchange.class); Headers headers = new Headers(); @@ -164,8 +159,7 @@ void deniedValidatorReturns403WithoutChallenge() throws Exception { Map.of("bearerAuth", new HttpBearer(Optional.empty())), List.of(), Map.of("bearerAuth", (req, cred) -> Optional.empty()), - false, - mockJsonMapper()); + false); HttpExchange ex = mock(HttpExchange.class); Headers headers = new Headers(); @@ -202,8 +196,7 @@ void apiKeyMissingReturnsApiKeyChallengeHeader() throws Exception { Map.of("apiKeyAuth", new ApiKey("X-API-Key", Location.HEADER)), List.of(), Map.of("apiKeyAuth", (req, cred) -> Optional.of("ok")), - false, - mockJsonMapper()); + false); HttpExchange ex = mock(HttpExchange.class); when(ex.getRequestHeaders()).thenReturn(new Headers()); @@ -248,8 +241,7 @@ void andGroupRequiresAllSchemesToSucceed() throws Exception { "bearerAuth", (req, cred) -> Optional.of("bearer-principal")); SecurityFilter filter = - new SecurityFilter( - Map.of("getX", op), schemes, List.of(), validators, false, mockJsonMapper()); + new SecurityFilter(Map.of("getX", op), schemes, List.of(), validators, false); HttpExchange ex = mock(HttpExchange.class); Headers headers = new Headers(); @@ -303,8 +295,7 @@ void orFallsBackToSecondGroupWhenFirstDenied() throws Exception { "bearerAuth", (req, cred) -> Optional.of("bearer-ok")); SecurityFilter filter = - new SecurityFilter( - Map.of("getX", op), schemes, List.of(), validators, false, mockJsonMapper()); + new SecurityFilter(Map.of("getX", op), schemes, List.of(), validators, false); HttpExchange ex = mock(HttpExchange.class); Headers headers = new Headers(); @@ -348,8 +339,7 @@ void externalAuthBypassesEverything() throws Exception { Map.of("bearerAuth", new HttpBearer(Optional.empty())), List.of(), Map.of(), // NO validators - /* externalAuth= */ true, - mockJsonMapper()); + /* externalAuth= */ true); HttpExchange ex = mock(HttpExchange.class); Chain chain = mock(Chain.class); @@ -361,24 +351,4 @@ void externalAuthBypassesEverything() throws Exception { private static Request newMinimalRequest(String operationId) { return new Request(new byte[0], null, null, operationId, Map.of(), null, h -> null); } - - private static TypeMapper mockJsonMapper() { - return new TypeMapper() { - @Override - public Object readFrom(byte[] body, String contentTypeHeader) { - return null; - } - - @Override - public byte[] writeTo(Object value) { - if (value instanceof ProblemDetail pd) { - return String.format( - "{\"type\":\"%s\",\"title\":\"%s\",\"status\":%d,\"detail\":\"%s\"}", - pd.type(), pd.title(), pd.status(), pd.detail()) - .getBytes(StandardCharsets.UTF_8); - } - return "{}".getBytes(StandardCharsets.UTF_8); - } - }; - } }