diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index 6b637bf..1ab6c12 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -7,8 +7,10 @@ import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; import static java.net.HttpURLConnection.HTTP_OK; import static java.net.HttpURLConnection.HTTP_UNAVAILABLE; +import static java.nio.charset.StandardCharsets.UTF_8; import com.retailsvc.http.internal.ClasspathResourceHandler; +import com.retailsvc.http.internal.HealthRenderer; import com.retailsvc.http.internal.ProblemDetail; import java.util.List; import java.util.Objects; @@ -61,10 +63,10 @@ public static RequestHandler aliveHandler() { /** * Health endpoint handler. Accepts GET and HEAD; returns 200 with {@code application/json} body - * when the supplied probe reports {@code up == true}, and 503 with the same body shape otherwise. - * A probe that throws a {@link RuntimeException} or returns {@code null} is mapped to a {@code - * Down} outcome with an empty dependency list (and 503); the failure is never propagated to the - * default exception handler. + * when the supplied probe reports up (all dependencies up, or no dependencies), and 503 with the + * same body shape otherwise. A probe that throws a {@link RuntimeException} or returns {@code + * null} is mapped to a {@code Down} response with an empty dependency list (and 503); the failure + * is never propagated to the default exception handler. * *

The wire shape is * @@ -72,52 +74,33 @@ public static RequestHandler aliveHandler() { * {"outcome":"Up","dependencies":[{"id":"jdbc","status":"Up"}]} * } * - *

Serialisation is delegated to the supplied {@code jsonMapper} — typically the same {@link - * TypeMapper} the caller registered for {@code application/json} on the server. The handler hands - * the mapper a record-shaped DTO with the components in the order shown above; any standard JSON - * library (Gson, Jackson, …) serialises it identically. + *

The body is rendered by a built-in writer; no JSON library on the classpath is required. * - * @param jsonMapper used to encode the wire-shape DTO to bytes * @param probe supplier of the current {@link HealthOutcome} */ - public static RequestHandler healthHandler(TypeMapper jsonMapper, Supplier probe) { - Objects.requireNonNull(jsonMapper, "jsonMapper"); + public static RequestHandler healthHandler(Supplier probe) { Objects.requireNonNull(probe, "probe"); return req -> { if (req.method() != GET && req.method() != HEAD) { return Response.status(HTTP_BAD_METHOD).withHeader("Allow", "GET, HEAD"); } - HealthOutcome outcome; + boolean up; + List dependencies; try { - outcome = Objects.requireNonNull(probe.get(), "Health probe returned null"); + HealthOutcome outcome = Objects.requireNonNull(probe.get(), "Health probe returned null"); + up = outcome.up(); + dependencies = outcome.dependencies(); } catch (RuntimeException e) { LOG.warn("Health probe failed", e); - outcome = new HealthOutcome(false, List.of()); + up = false; + dependencies = List.of(); } - byte[] body = jsonMapper.writeTo(toWireShape(outcome)); - int status = outcome.up() ? HTTP_OK : HTTP_UNAVAILABLE; + byte[] body = HealthRenderer.renderJson(up, dependencies).getBytes(UTF_8); + int status = up ? HTTP_OK : HTTP_UNAVAILABLE; return Response.bytes(status, body, "application/json"); }; } - private static HealthBody toWireShape(HealthOutcome outcome) { - return new HealthBody( - label(outcome.up()), - outcome.dependencies().stream() - .map(d -> new DependencyBody(d.id(), label(d.up()))) - .toList()); - } - - private static String label(boolean up) { - return up ? "Up" : "Down"; - } - - /** Wire-shape DTO for the health endpoint. Component order defines JSON field order. */ - private record HealthBody(String outcome, List dependencies) {} - - /** Wire-shape DTO for a single dependency entry. */ - private record DependencyBody(String id, String status) {} - /** * Serves a classpath resource. Content-Type is inferred from the file extension. The resource is * loaded eagerly; a missing resource fails immediately with {@link IllegalArgumentException}. diff --git a/src/main/java/com/retailsvc/http/HealthOutcome.java b/src/main/java/com/retailsvc/http/HealthOutcome.java index 8950e4f..9db0c05 100644 --- a/src/main/java/com/retailsvc/http/HealthOutcome.java +++ b/src/main/java/com/retailsvc/http/HealthOutcome.java @@ -6,17 +6,20 @@ /** * Carrier for the {@link Handlers#healthHandler health handler} response. * - *

The library translates {@code up} into the wire value {@code "Up"} or {@code "Down"} on the - * way out; callers work in booleans. Construct the outcome from whatever check-running mechanism - * the caller prefers — this library has no opinion. + *

Overall health is derived from {@link #dependencies()}: an empty list reports as {@code "Up"}; + * otherwise the outcome is {@code "Up"} only when every dependency is up. Callers describe their + * dependencies and the library aggregates. * - * @param up overall health — {@code true} renders as {@code "Up"} with HTTP 200; {@code false} - * renders as {@code "Down"} with HTTP 503 * @param dependencies per-dependency statuses; {@code null} is normalised to an empty list */ -public record HealthOutcome(boolean up, List dependencies) { +public record HealthOutcome(List dependencies) { public HealthOutcome { dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of())); } + + /** {@code true} when every dependency is up (vacuously true for an empty list). */ + public boolean up() { + return dependencies.stream().allMatch(Dependency::up); + } } diff --git a/src/main/java/com/retailsvc/http/internal/HealthRenderer.java b/src/main/java/com/retailsvc/http/internal/HealthRenderer.java new file mode 100644 index 0000000..cec7e45 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/HealthRenderer.java @@ -0,0 +1,59 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.Dependency; +import java.util.List; + +/** + * Built-in JSON writer for the health endpoint wire shape. Keeps {@code Handlers} free of + * serialisation logic and removes the need for a JSON library on the classpath. + */ +public final class HealthRenderer { + + /** Initial StringBuilder capacity sized for an outcome with one dependency (the common case). */ + private static final int INITIAL_CAPACITY = 64; + + private HealthRenderer() {} + + public static String renderJson(boolean up, List dependencies) { + StringBuilder sb = new StringBuilder(INITIAL_CAPACITY); + sb.append("{\"outcome\":\"").append(label(up)).append("\",\"dependencies\":["); + for (int i = 0; i < dependencies.size(); i++) { + if (i > 0) { + sb.append(','); + } + Dependency d = dependencies.get(i); + sb.append("{\"id\":"); + appendJsonString(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/test/java/com/retailsvc/http/HealthHandlerTest.java b/src/test/java/com/retailsvc/http/HealthHandlerTest.java index cbee414..e261480 100644 --- a/src/test/java/com/retailsvc/http/HealthHandlerTest.java +++ b/src/test/java/com/retailsvc/http/HealthHandlerTest.java @@ -8,7 +8,6 @@ import static java.net.HttpURLConnection.HTTP_UNAVAILABLE; import static org.assertj.core.api.Assertions.assertThat; -import com.retailsvc.http.internal.gson.GsonJsonMapper; import com.retailsvc.http.spec.HttpMethod; import java.nio.charset.StandardCharsets; import java.util.List; @@ -19,7 +18,6 @@ class HealthHandlerTest { - private static final TypeMapper JSON = new GsonJsonMapper(); private static final UnaryOperator NO_HEADERS = name -> null; private static Request request(HttpMethod method) { @@ -27,10 +25,10 @@ private static Request request(HttpMethod method) { } @Test - void getReturns200AndJsonBodyWhenUp() { - HealthOutcome outcome = new HealthOutcome(true, List.of(new Dependency("jdbc", true))); + void getReturns200AndJsonBodyWhenAllDependenciesUp() { + HealthOutcome outcome = new HealthOutcome(List.of(new Dependency("jdbc", true))); - Response resp = Handlers.healthHandler(JSON, () -> outcome).handle(request(GET)); + Response resp = Handlers.healthHandler(() -> outcome).handle(request(GET)); assertThat(resp.status()).isEqualTo(HTTP_OK); assertThat(resp.contentType()).isEqualTo("application/json"); @@ -40,8 +38,7 @@ void getReturns200AndJsonBodyWhenUp() { @Test void getReturns200WithEmptyDependencyArrayWhenNoDeps() { - Response resp = - Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of())).handle(request(GET)); + Response resp = Handlers.healthHandler(() -> new HealthOutcome(List.of())).handle(request(GET)); assertThat(resp.status()).isEqualTo(HTTP_OK); assertThat(new String((byte[]) resp.body(), StandardCharsets.UTF_8)) @@ -49,10 +46,10 @@ void getReturns200WithEmptyDependencyArrayWhenNoDeps() { } @Test - void getReturns503WhenDown() { - HealthOutcome outcome = new HealthOutcome(false, List.of(new Dependency("jdbc", false))); + void getReturns503WhenAnyDependencyDown() { + HealthOutcome outcome = new HealthOutcome(List.of(new Dependency("jdbc", false))); - Response resp = Handlers.healthHandler(JSON, () -> outcome).handle(request(GET)); + Response resp = Handlers.healthHandler(() -> outcome).handle(request(GET)); assertThat(resp.status()).isEqualTo(HTTP_UNAVAILABLE); assertThat(resp.contentType()).isEqualTo("application/json"); @@ -64,8 +61,7 @@ void getReturns503WhenDown() { @Test void headIsAccepted() { Response resp = - Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of())) - .handle(request(HEAD)); + Handlers.healthHandler(() -> new HealthOutcome(List.of())).handle(request(HEAD)); assertThat(resp.status()).isEqualTo(HTTP_OK); } @@ -73,8 +69,7 @@ void headIsAccepted() { @Test void postReturns405WithAllowHeader() { Response resp = - Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of())) - .handle(request(POST)); + Handlers.healthHandler(() -> new HealthOutcome(List.of())).handle(request(POST)); assertThat(resp.status()).isEqualTo(HTTP_BAD_METHOD); assertThat(resp.headers()).containsEntry("Allow", "GET, HEAD"); @@ -87,7 +82,7 @@ void runtimeExceptionFromProbeMapsToDown503() { throw new IllegalStateException("boom"); }; - Response resp = Handlers.healthHandler(JSON, failing).handle(request(GET)); + Response resp = Handlers.healthHandler(failing).handle(request(GET)); assertThat(resp.status()).isEqualTo(HTTP_UNAVAILABLE); assertThat(new String((byte[]) resp.body(), StandardCharsets.UTF_8)) @@ -96,10 +91,22 @@ void runtimeExceptionFromProbeMapsToDown503() { @Test void nullReturnFromProbeMapsToDown503() { - Response resp = Handlers.healthHandler(JSON, () -> null).handle(request(GET)); + Response resp = Handlers.healthHandler(() -> null).handle(request(GET)); assertThat(resp.status()).isEqualTo(HTTP_UNAVAILABLE); assertThat(new String((byte[]) resp.body(), StandardCharsets.UTF_8)) .isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}"); } + + @Test + void escapesSpecialCharsInDependencyId() { + HealthOutcome outcome = new HealthOutcome(List.of(new Dependency("a\"b\\c\nd", true))); + + Response resp = Handlers.healthHandler(() -> outcome).handle(request(GET)); + + assertThat(new String((byte[]) resp.body(), StandardCharsets.UTF_8)) + .isEqualTo( + "{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"a\\\"b\\\\c\\n" + + "d\",\"status\":\"Up\"}]}"); + } } diff --git a/src/test/java/com/retailsvc/http/HealthOutcomeTest.java b/src/test/java/com/retailsvc/http/HealthOutcomeTest.java index 0ef6a86..b840f98 100644 --- a/src/test/java/com/retailsvc/http/HealthOutcomeTest.java +++ b/src/test/java/com/retailsvc/http/HealthOutcomeTest.java @@ -9,15 +9,14 @@ class HealthOutcomeTest { @Test - void exposesUpAndDependencies() { - HealthOutcome o = new HealthOutcome(true, List.of(new Dependency("jdbc", true))); - assertThat(o.up()).isTrue(); + void exposesDependencies() { + HealthOutcome o = new HealthOutcome(List.of(new Dependency("jdbc", true))); assertThat(o.dependencies()).containsExactly(new Dependency("jdbc", true)); } @Test void coercesNullDependenciesToEmpty() { - HealthOutcome o = new HealthOutcome(true, null); + HealthOutcome o = new HealthOutcome(null); assertThat(o.dependencies()).isEmpty(); } @@ -25,8 +24,27 @@ void coercesNullDependenciesToEmpty() { void copiesDependencyListDefensively() { List mutable = new ArrayList<>(); mutable.add(new Dependency("jdbc", true)); - HealthOutcome o = new HealthOutcome(true, mutable); + HealthOutcome o = new HealthOutcome(mutable); mutable.clear(); assertThat(o.dependencies()).hasSize(1); } + + @Test + void emptyDependenciesIsUp() { + assertThat(new HealthOutcome(List.of()).up()).isTrue(); + } + + @Test + void upWhenAllDependenciesUp() { + HealthOutcome o = + new HealthOutcome(List.of(new Dependency("jdbc", true), new Dependency("redis", true))); + assertThat(o.up()).isTrue(); + } + + @Test + void downWhenAnyDependencyDown() { + HealthOutcome o = + new HealthOutcome(List.of(new Dependency("jdbc", true), new Dependency("redis", false))); + assertThat(o.up()).isFalse(); + } } diff --git a/src/test/java/com/retailsvc/http/internal/HealthRendererTest.java b/src/test/java/com/retailsvc/http/internal/HealthRendererTest.java new file mode 100644 index 0000000..3da0662 --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/HealthRendererTest.java @@ -0,0 +1,67 @@ +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.retailsvc.http.Dependency; +import java.util.List; +import org.junit.jupiter.api.Test; + +class HealthRendererTest { + + @Test + void rendersUpWithNoDependencies() { + assertThat(HealthRenderer.renderJson(true, List.of())) + .isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[]}"); + } + + @Test + void rendersDownWithNoDependencies() { + assertThat(HealthRenderer.renderJson(false, List.of())) + .isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}"); + } + + @Test + void rendersSingleDependency() { + assertThat(HealthRenderer.renderJson(true, List.of(new Dependency("jdbc", true)))) + .isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"jdbc\",\"status\":\"Up\"}]}"); + } + + @Test + void rendersMultipleDependenciesInOrderWithCommaSeparators() { + List deps = List.of(new Dependency("jdbc", true), new Dependency("redis", false)); + assertThat(HealthRenderer.renderJson(false, deps)) + .isEqualTo( + "{\"outcome\":\"Down\",\"dependencies\":[" + + "{\"id\":\"jdbc\",\"status\":\"Up\"}," + + "{\"id\":\"redis\",\"status\":\"Down\"}]}"); + } + + @Test + void escapesQuoteAndBackslashInId() { + assertThat(HealthRenderer.renderJson(true, List.of(new Dependency("a\"b\\c", true)))) + .isEqualTo( + "{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"a\\\"b\\\\c\",\"status\":\"Up\"}]}"); + } + + @Test + void escapesNamedControlCharsInId() { + assertThat(HealthRenderer.renderJson(true, List.of(new Dependency("\b\f\n\r\t", true)))) + .isEqualTo( + "{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"\\b\\f\\n" + + "\\r" + + "\\t\",\"status\":\"Up\"}]}"); + } + + @Test + void escapesUnnamedControlCharsAsHexUnicode() { + assertThat(HealthRenderer.renderJson(true, List.of(new Dependency("", true)))) + .isEqualTo( + "{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"\\u0001\\u001f\",\"status\":\"Up\"}]}"); + } + + @Test + void passesThroughNonAsciiCharactersVerbatim() { + assertThat(HealthRenderer.renderJson(true, List.of(new Dependency("café-é", true)))) + .isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"café-é\",\"status\":\"Up\"}]}"); + } +}