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\"}]}");
+ }
+}