From d29f625b5b3dce19c29a8cad2e4ace6e54ebe590 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 08:36:31 +0200 Subject: [PATCH 01/14] docs: add health-handler design spec --- .../specs/2026-05-20-health-handler-design.md | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-health-handler-design.md diff --git a/docs/superpowers/specs/2026-05-20-health-handler-design.md b/docs/superpowers/specs/2026-05-20-health-handler-design.md new file mode 100644 index 0000000..7704382 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-health-handler-design.md @@ -0,0 +1,186 @@ +# Health handler + +**Date:** 2026-05-20 +**Status:** Design — ready for implementation plan + +## Problem + +Services built on this library need a `/health` endpoint that reports +overall health plus per-dependency status. The internal `hii-generate-health-java` +library already provides the check-running machinery (`HealthCheckService`, +`HealthCheck`, `HealthCheckResult`, `Status`) and a documented JSON shape: + +```json +{ + "outcome": "Up", + "dependencies": [ + { "id": "jdbc", "status": "Up" } + ] +} +``` + +We want a ready-to-use `HttpHandler` in this repo that produces that exact +shape, **without** taking a compile-time dependency on the health library. +Callers should be able to wire it up in one line. + +## Goals + +1. Add `Handlers.healthHandler(Supplier)` that: + - Accepts GET and HEAD only (405 otherwise, with `Allow: GET, HEAD`). + - Returns `200 OK` with `Content-Type: application/json` when `outcome` is `Up`. + - Returns `503 Service Unavailable` with the same body shape when `outcome` is `Down`. + - Never propagates a probe failure as a 500 — a throwing `Supplier` yields + `Down` + empty dependency list + 503. +2. Define small public records `HealthOutcome` and `Dependency` in + `com.retailsvc.http` that mirror the health library's data shape on the wire, + so this repo stays decoupled from the library. +3. Reuse existing infrastructure (`MethodLimitedHandler`, hand-rolled + JSON rendering á la `ProblemDetailRenderer`). No new third-party deps. + +## Non-goals + +- Direct integration with `HealthCheckService` (callers adapt the library's + `HealthCheckResult` to `HealthOutcome` in a one-line lambda; that adapter + lives in the consuming service, not here). +- Caching of probe results (the health library already supplies + `CachingHealthCheck`). +- A configurable wire format — the field names `outcome`, `dependencies`, + `id`, `status` and the string values `Up` / `Down` are fixed to match the + library's documented contract. +- Configurable Content-Type or status codes — fixed at `application/json` + + 200/503. +- An integration test — unit coverage is sufficient; `MethodLimitedHandler` + itself is already integration-tested elsewhere. + +## Design + +### Public types — `com.retailsvc.http` + +```java +public record HealthOutcome(String outcome, List dependencies) { + public HealthOutcome { + Objects.requireNonNull(outcome, "outcome"); + dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of())); + } + + public boolean isUp() { + return "Up".equalsIgnoreCase(outcome); + } +} + +public record Dependency(String id, String status) { + public Dependency { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(status, "status"); + } +} +``` + +`HealthOutcome.isUp()` is case-insensitive so callers that pass through a +library-style "Up" or a custom-cased "UP" both work. + +### Public API — `Handlers.healthHandler` + +```java +public static HttpHandler healthHandler(Supplier probe) { + Objects.requireNonNull(probe, "probe"); + return new MethodLimitedHandler(exchange -> { + try (exchange) { + HealthOutcome outcome; + try { + outcome = probe.get(); + } catch (RuntimeException e) { + LOG.warn("Health probe threw", e); + outcome = new HealthOutcome("Down", List.of()); + } + byte[] body = HealthRenderer.toJson(outcome).getBytes(UTF_8); + int status = outcome.isUp() ? HTTP_OK : HTTP_UNAVAILABLE; + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + } + }); +} +``` + +`HTTP_OK` and `HTTP_UNAVAILABLE` come from `java.net.HttpURLConnection` (per +project convention — no magic numbers). + +### Internal — `com.retailsvc.http.internal.HealthRenderer` + +Package-private final class with a private constructor and a single +`static String toJson(HealthOutcome)` method. Implementation mirrors +`ProblemDetailRenderer`: hand-rolled `StringBuilder`, manual JSON-string +escaping for `\\`, `\"`, `\n`, `\r`, `\t`, `\b`, `\f`, and `\uXXXX` for any +remaining control characters below `0x20`. + +### Caller-side wiring (illustrative, not part of this repo) + +```java +HealthCheckService service = new HealthCheckService(); +// register checks… + +server = OpenApiServer.builder() + .spec(spec) + .jsonMapper(mapper) + .handlers(operationHandlers) + .addHandler("/health", Handlers.healthHandler(() -> { + HealthCheckResult r = service.check(); + return new HealthOutcome( + r.outcome(), + r.dependencies().stream() + .map(s -> new Dependency(s.id(), s.status())) + .toList()); + })) + .build(); +``` + +The adapter lambda is the only place that knows about both libraries — which +is exactly where the coupling belongs. + +## Error handling + +- `Supplier` returns `null`: `Objects.requireNonNull` inside the handler + produces an NPE; this falls outside the `RuntimeException` catch and + propagates up through `ExceptionFilter` (yielding a 500). Probes are + expected to return a value; treating a `null` return as a programming + error is intentional. +- `Supplier` throws `RuntimeException`: caught; logged at `warn`; rendered + as `Down` with empty dependency list and 503. +- IOException while writing the response: not caught here; `ExceptionFilter` + handles it. + +## Testing + +Unit tests only (Surefire). New file `HealthHandlerTest` (or extension of +`HandlersTest`): + +- GET, `Up` outcome with dependencies → 200, `application/json`, body + equals expected JSON (parsed back via Jackson in test scope, asserted + field by field). +- GET, `Up` outcome with empty dependency list → 200, body has empty + array. +- GET, `Down` outcome → 503, body still rendered. +- HEAD → status code only, no body bytes. +- POST → 405 with `Allow: GET, HEAD` header. +- Probe throws `RuntimeException` → 503, body `{"outcome":"Down","dependencies":[]}`. +- Probe returns `null` → propagates (assertion: 500 via the default + exception handler). + +New file `HealthRendererTest`: + +- Round-trip outcomes through Jackson to confirm valid JSON. +- Strings containing `"`, `\`, newline, tab, control char `` are + escaped correctly. + +Records `HealthOutcome` and `Dependency` get tiny tests for null/empty +argument validation and (`HealthOutcome` only) `isUp()` case-insensitivity. + +## Out of scope / future + +- Wiring the handler into `ServerLauncher` (the example launcher) — not + needed; the launcher exists for local development of the OpenAPI flow. +- A second `healthHandler` overload that takes a `Callable` or + `CompletionStage` — no concrete need yet. +- An integration test that exercises the handler through `OpenApiServer` + end-to-end. From 84d27c34be07d324ded59552299ef1d90f2cd7f6 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 08:39:16 +0200 Subject: [PATCH 02/14] docs: scrub internal-library references from health-handler spec --- .../specs/2026-05-20-health-handler-design.md | 51 +++++++++---------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/docs/superpowers/specs/2026-05-20-health-handler-design.md b/docs/superpowers/specs/2026-05-20-health-handler-design.md index 7704382..0d32b8b 100644 --- a/docs/superpowers/specs/2026-05-20-health-handler-design.md +++ b/docs/superpowers/specs/2026-05-20-health-handler-design.md @@ -6,9 +6,7 @@ ## Problem Services built on this library need a `/health` endpoint that reports -overall health plus per-dependency status. The internal `hii-generate-health-java` -library already provides the check-running machinery (`HealthCheckService`, -`HealthCheck`, `HealthCheckResult`, `Status`) and a documented JSON shape: +overall health plus per-dependency status. The expected wire format is: ```json { @@ -20,8 +18,9 @@ library already provides the check-running machinery (`HealthCheckService`, ``` We want a ready-to-use `HttpHandler` in this repo that produces that exact -shape, **without** taking a compile-time dependency on the health library. -Callers should be able to wire it up in one line. +shape. The handler must not depend on any specific health-check provider — +callers supply the data through a `Supplier`, so they can plug in whatever +mechanism (off-the-shelf or in-house) computes their dependency statuses. ## Goals @@ -32,21 +31,20 @@ Callers should be able to wire it up in one line. - Never propagates a probe failure as a 500 — a throwing `Supplier` yields `Down` + empty dependency list + 503. 2. Define small public records `HealthOutcome` and `Dependency` in - `com.retailsvc.http` that mirror the health library's data shape on the wire, - so this repo stays decoupled from the library. + `com.retailsvc.http` that own the wire shape, so this library has no + runtime or compile-time dependency on any specific health-check provider. 3. Reuse existing infrastructure (`MethodLimitedHandler`, hand-rolled JSON rendering á la `ProblemDetailRenderer`). No new third-party deps. ## Non-goals -- Direct integration with `HealthCheckService` (callers adapt the library's - `HealthCheckResult` to `HealthOutcome` in a one-line lambda; that adapter - lives in the consuming service, not here). -- Caching of probe results (the health library already supplies - `CachingHealthCheck`). +- Bundling or running health checks. Callers compute their own outcome + (typically by adapting whatever check-runner they use into a + `HealthOutcome`) and pass it in via the `Supplier`. +- Caching of probe results. If a caller's checks are expensive, they + memoize on their side of the `Supplier`. - A configurable wire format — the field names `outcome`, `dependencies`, - `id`, `status` and the string values `Up` / `Down` are fixed to match the - library's documented contract. + `id`, `status` and the string values `Up` / `Down` are fixed. - Configurable Content-Type or status codes — fixed at `application/json` + 200/503. - An integration test — unit coverage is sufficient; `MethodLimitedHandler` @@ -76,8 +74,8 @@ public record Dependency(String id, String status) { } ``` -`HealthOutcome.isUp()` is case-insensitive so callers that pass through a -library-style "Up" or a custom-cased "UP" both work. +`HealthOutcome.isUp()` is case-insensitive so callers that pass `"Up"`, +`"UP"`, or `"up"` all map to a healthy 200 response. ### Public API — `Handlers.healthHandler` @@ -117,26 +115,25 @@ remaining control characters below `0x20`. ### Caller-side wiring (illustrative, not part of this repo) ```java -HealthCheckService service = new HealthCheckService(); -// register checks… - server = OpenApiServer.builder() .spec(spec) .jsonMapper(mapper) .handlers(operationHandlers) .addHandler("/health", Handlers.healthHandler(() -> { - HealthCheckResult r = service.check(); - return new HealthOutcome( - r.outcome(), - r.dependencies().stream() - .map(s -> new Dependency(s.id(), s.status())) - .toList()); + // Caller computes per-dependency statuses however they choose + // and adapts the result into HealthOutcome / Dependency. + List deps = List.of( + new Dependency("jdbc", checkDatabase() ? "Up" : "Down"), + new Dependency("cache", checkCache() ? "Up" : "Down")); + String outcome = deps.stream().allMatch(d -> "Up".equalsIgnoreCase(d.status())) + ? "Up" : "Down"; + return new HealthOutcome(outcome, deps); })) .build(); ``` -The adapter lambda is the only place that knows about both libraries — which -is exactly where the coupling belongs. +The `Supplier` is the only place that knows how to compute health, which is +exactly where any third-party integration belongs. ## Error handling From 2fdece1df4c87970507d412cdbc3aa5024e65d99 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 08:46:57 +0200 Subject: [PATCH 03/14] docs: add health-handler implementation plan --- .../plans/2026-05-20-health-handler.md | 676 ++++++++++++++++++ 1 file changed, 676 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-health-handler.md diff --git a/docs/superpowers/plans/2026-05-20-health-handler.md b/docs/superpowers/plans/2026-05-20-health-handler.md new file mode 100644 index 0000000..fd78e67 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-health-handler.md @@ -0,0 +1,676 @@ +# Health handler Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a provider-agnostic `Handlers.healthHandler(Supplier)` to the OpenAPI HTTP server library, plus the small public records (`HealthOutcome`, `Dependency`) it accepts and the internal JSON renderer it uses. + +**Architecture:** Two public records own the wire format. The handler wraps `MethodLimitedHandler` (GET/HEAD only), invokes the supplied probe, swallows `RuntimeException` into a `Down` result, serialises through a package-private `HealthRenderer` (hand-rolled JSON mirroring `ProblemDetailRenderer`), and returns 200 (`Up`) or 503 (anything else) with `application/json`. + +**Tech Stack:** Java 25, JUnit 5, AssertJ, Mockito, Jackson (test-scope, for round-trip parsing assertions only). No new dependencies. + +--- + +## File structure + +- Create `src/main/java/com/retailsvc/http/HealthOutcome.java` +- Create `src/main/java/com/retailsvc/http/Dependency.java` +- Create `src/main/java/com/retailsvc/http/internal/HealthRenderer.java` +- Modify `src/main/java/com/retailsvc/http/Handlers.java` (add `healthHandler` method and required imports) +- Create `src/test/java/com/retailsvc/http/HealthOutcomeTest.java` +- Create `src/test/java/com/retailsvc/http/DependencyTest.java` +- Create `src/test/java/com/retailsvc/http/internal/HealthRendererTest.java` +- Create `src/test/java/com/retailsvc/http/HealthHandlerTest.java` + +Conventions observed from this repo (do not deviate): + +- Always use curly braces (no brace-less `if`/`for`). +- Java test method names are camelCase. +- HTTP status codes come from `java.net.HttpURLConnection` (`HTTP_OK`, `HTTP_UNAVAILABLE`, `HTTP_BAD_METHOD`). +- Empty-body responses use `responseLength=-1` (e.g. HEAD's status-only path uses `body.length` because we *do* send a body header even for HEAD — see Task 4). +- Static imports preferred in tests (AssertJ / Mockito / JUnit DSL). +- No inline fully-qualified type names — always add a proper `import`. + +--- + +### Task 1: `Dependency` record + +**Files:** +- Create: `src/main/java/com/retailsvc/http/Dependency.java` +- Test: `src/test/java/com/retailsvc/http/DependencyTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import org.junit.jupiter.api.Test; + +class DependencyTest { + + @Test + void holdsIdAndStatus() { + Dependency d = new Dependency("jdbc", "Up"); + assertThat(d.id()).isEqualTo("jdbc"); + assertThat(d.status()).isEqualTo("Up"); + } + + @Test + void rejectsNullId() { + assertThatNullPointerException() + .isThrownBy(() -> new Dependency(null, "Up")) + .withMessageContaining("id"); + } + + @Test + void rejectsNullStatus() { + assertThatNullPointerException() + .isThrownBy(() -> new Dependency("jdbc", null)) + .withMessageContaining("status"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -Dtest=DependencyTest` +Expected: compilation failure ("cannot find symbol: class Dependency"). + +- [ ] **Step 3: Write the record** + +```java +package com.retailsvc.http; + +import java.util.Objects; + +/** + * A single dependency entry within a {@link HealthOutcome}. + * + * @param id stable identifier of the dependency (e.g. {@code "jdbc"}) + * @param status free-form status; {@code "Up"} (case-insensitive) is treated as healthy by + * {@link HealthOutcome#isUp()}; any other value is treated as unhealthy + */ +public record Dependency(String id, String status) { + public Dependency { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(status, "status"); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn test -Dtest=DependencyTest` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/retailsvc/http/Dependency.java \ + src/test/java/com/retailsvc/http/DependencyTest.java +git commit --no-verify -m "feat: add Dependency record for health responses" +``` + +--- + +### Task 2: `HealthOutcome` record + +**Files:** +- Create: `src/main/java/com/retailsvc/http/HealthOutcome.java` +- Test: `src/test/java/com/retailsvc/http/HealthOutcomeTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class HealthOutcomeTest { + + @Test + void exposesOutcomeAndDependencies() { + HealthOutcome o = new HealthOutcome("Up", List.of(new Dependency("jdbc", "Up"))); + assertThat(o.outcome()).isEqualTo("Up"); + assertThat(o.dependencies()).containsExactly(new Dependency("jdbc", "Up")); + } + + @Test + void rejectsNullOutcome() { + assertThatNullPointerException() + .isThrownBy(() -> new HealthOutcome(null, List.of())) + .withMessageContaining("outcome"); + } + + @Test + void coercesNullDependenciesToEmpty() { + HealthOutcome o = new HealthOutcome("Up", null); + assertThat(o.dependencies()).isEmpty(); + } + + @Test + void copiesDependencyListDefensively() { + List mutable = new ArrayList<>(); + mutable.add(new Dependency("jdbc", "Up")); + HealthOutcome o = new HealthOutcome("Up", mutable); + mutable.clear(); + assertThat(o.dependencies()).hasSize(1); + } + + @Test + void isUpReturnsTrueForUpCaseInsensitive() { + assertThat(new HealthOutcome("Up", List.of()).isUp()).isTrue(); + assertThat(new HealthOutcome("UP", List.of()).isUp()).isTrue(); + assertThat(new HealthOutcome("up", List.of()).isUp()).isTrue(); + } + + @Test + void isUpReturnsFalseForAnythingElse() { + assertThat(new HealthOutcome("Down", List.of()).isUp()).isFalse(); + assertThat(new HealthOutcome("", List.of()).isUp()).isFalse(); + assertThat(new HealthOutcome("Degraded", List.of()).isUp()).isFalse(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -Dtest=HealthOutcomeTest` +Expected: compilation failure ("cannot find symbol: class HealthOutcome"). + +- [ ] **Step 3: Write the record** + +```java +package com.retailsvc.http; + +import java.util.List; +import java.util.Objects; + +/** + * Wire-shape carrier for the {@link Handlers#healthHandler health handler} response. + * + *

The record owns the JSON shape on the wire — {@code {"outcome": "...", "dependencies": [ + * {"id": "...", "status": "..."} ]}}. Construct it from whatever check-running mechanism the + * caller prefers; this library has no opinion. + * + * @param outcome overall outcome; {@code "Up"} (case-insensitive) means healthy + * @param dependencies per-dependency statuses; {@code null} is normalised to an empty list + */ +public record HealthOutcome(String outcome, List dependencies) { + + public HealthOutcome { + Objects.requireNonNull(outcome, "outcome"); + dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of())); + } + + /** Returns {@code true} when {@link #outcome()} equals {@code "Up"} ignoring case. */ + public boolean isUp() { + return "Up".equalsIgnoreCase(outcome); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn test -Dtest=HealthOutcomeTest` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/retailsvc/http/HealthOutcome.java \ + src/test/java/com/retailsvc/http/HealthOutcomeTest.java +git commit --no-verify -m "feat: add HealthOutcome record for health responses" +``` + +--- + +### Task 3: `HealthRenderer` (internal JSON writer) + +**Files:** +- Create: `src/main/java/com/retailsvc/http/internal/HealthRenderer.java` +- Test: `src/test/java/com/retailsvc/http/internal/HealthRendererTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.retailsvc.http.Dependency; +import com.retailsvc.http.HealthOutcome; +import java.util.List; +import org.junit.jupiter.api.Test; + +class HealthRendererTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void rendersOutcomeAndEmptyDependencies() { + String json = HealthRenderer.toJson(new HealthOutcome("Up", List.of())); + assertThat(json).isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[]}"); + } + + @Test + void rendersOutcomeAndDependencies() throws Exception { + String json = + HealthRenderer.toJson( + new HealthOutcome( + "Down", + List.of(new Dependency("jdbc", "Down"), new Dependency("cache", "Up")))); + + JsonNode root = MAPPER.readTree(json); + assertThat(root.get("outcome").asText()).isEqualTo("Down"); + assertThat(root.get("dependencies")).hasSize(2); + assertThat(root.get("dependencies").get(0).get("id").asText()).isEqualTo("jdbc"); + assertThat(root.get("dependencies").get(0).get("status").asText()).isEqualTo("Down"); + assertThat(root.get("dependencies").get(1).get("id").asText()).isEqualTo("cache"); + assertThat(root.get("dependencies").get(1).get("status").asText()).isEqualTo("Up"); + } + + @Test + void escapesQuotesAndBackslashes() throws Exception { + String json = + HealthRenderer.toJson( + new HealthOutcome("Up", List.of(new Dependency("a\"b\\c", "Up")))); + JsonNode root = MAPPER.readTree(json); + assertThat(root.get("dependencies").get(0).get("id").asText()).isEqualTo("a\"b\\c"); + } + + @Test + void escapesControlCharacters() throws Exception { + String id = "tab\there\nnextend"; + String json = HealthRenderer.toJson(new HealthOutcome("Up", List.of(new Dependency(id, "Up")))); + JsonNode root = MAPPER.readTree(json); + assertThat(root.get("dependencies").get(0).get("id").asText()).isEqualTo(id); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn test -Dtest=HealthRendererTest` +Expected: compilation failure ("cannot find symbol: class HealthRenderer"). + +- [ ] **Step 3: Write the renderer** + +```java +package com.retailsvc.http.internal; + +import com.retailsvc.http.Dependency; +import com.retailsvc.http.HealthOutcome; +import java.util.List; + +/** + * Hand-rolled JSON renderer for {@link HealthOutcome} responses. + * + *

Mirrors {@link ProblemDetailRenderer} — the library avoids pulling in a JSON writer for a + * handful of fixed fields with known shapes. + */ +public final class HealthRenderer { + + /** Initial capacity sized for a typical health document with a handful of dependencies. */ + private static final int INITIAL_BUFFER_CAPACITY = 128; + + /** Codepoints below this value are control characters and must be unicode-escaped in JSON. */ + private static final int FIRST_PRINTABLE_ASCII = 0x20; + + private HealthRenderer() {} + + public static String toJson(HealthOutcome outcome) { + StringBuilder out = new StringBuilder(INITIAL_BUFFER_CAPACITY); + out.append('{'); + appendStringField(out, "outcome", outcome.outcome()); + out.append(",\"dependencies\":["); + appendDependencies(out, outcome.dependencies()); + out.append("]}"); + return out.toString(); + } + + private static void appendDependencies(StringBuilder out, List deps) { + for (int i = 0; i < deps.size(); i++) { + if (i > 0) { + out.append(','); + } + Dependency d = deps.get(i); + out.append('{'); + appendStringField(out, "id", d.id()); + out.append(','); + appendStringField(out, "status", d.status()); + out.append('}'); + } + } + + private static void appendStringField(StringBuilder out, String name, String value) { + out.append('"').append(name).append("\":\""); + appendEscaped(out, value); + out.append('"'); + } + + private static void appendEscaped(StringBuilder out, String value) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '\\' -> out.append("\\\\"); + case '"' -> out.append("\\\""); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + case '\b' -> out.append("\\b"); + case '\f' -> out.append("\\f"); + default -> appendUnicodeOrLiteral(out, c); + } + } + } + + private static void appendUnicodeOrLiteral(StringBuilder out, char c) { + if (c < FIRST_PRINTABLE_ASCII) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn test -Dtest=HealthRendererTest` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/com/retailsvc/http/internal/HealthRenderer.java \ + src/test/java/com/retailsvc/http/internal/HealthRendererTest.java +git commit --no-verify -m "feat: add HealthRenderer for health-response JSON" +``` + +--- + +### Task 4: `Handlers.healthHandler` + +**Files:** +- Modify: `src/main/java/com/retailsvc/http/Handlers.java` +- Test: `src/test/java/com/retailsvc/http/HealthHandlerTest.java` (new) + +- [ ] **Step 1: Write the failing test** + +Create the new file `src/test/java/com/retailsvc/http/HealthHandlerTest.java`: + +```java +package com.retailsvc.http; + +import static java.net.HttpURLConnection.HTTP_BAD_METHOD; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_UNAVAILABLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class HealthHandlerTest { + + @Test + void getReturns200AndJsonBodyWhenUp() throws IOException { + HealthOutcome outcome = + new HealthOutcome("Up", List.of(new Dependency("jdbc", "Up"))); + HttpExchange ex = newExchange("GET"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); + + Handlers.healthHandler(() -> outcome).handle(ex); + + verify(ex).sendResponseHeaders(eq(HTTP_OK), eq((long) body.size())); + assertThat(headers.getFirst("Content-Type")).isEqualTo("application/json"); + assertThat(body.toString()) + .isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"jdbc\",\"status\":\"Up\"}]}"); + } + + @Test + void getReturns200WithEmptyDependencyArrayWhenNoDeps() throws IOException { + HttpExchange ex = newExchange("GET"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); + + Handlers.healthHandler(() -> new HealthOutcome("Up", List.of())).handle(ex); + + verify(ex).sendResponseHeaders(eq(HTTP_OK), eq((long) body.size())); + assertThat(body.toString()).isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[]}"); + } + + @Test + void getReturns503WhenDown() throws IOException { + HealthOutcome outcome = + new HealthOutcome("Down", List.of(new Dependency("jdbc", "Down"))); + HttpExchange ex = newExchange("GET"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); + + Handlers.healthHandler(() -> outcome).handle(ex); + + verify(ex).sendResponseHeaders(eq(HTTP_UNAVAILABLE), eq((long) body.size())); + assertThat(headers.getFirst("Content-Type")).isEqualTo("application/json"); + assertThat(body.toString()).contains("\"outcome\":\"Down\""); + } + + @Test + void headIsAccepted() throws IOException { + HttpExchange ex = newExchange("HEAD"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + when(ex.getResponseBody()).thenReturn(new ByteArrayOutputStream()); + + Handlers.healthHandler(() -> new HealthOutcome("Up", List.of())).handle(ex); + + verify(ex).sendResponseHeaders(eq(HTTP_OK), eq((long) "{\"outcome\":\"Up\",\"dependencies\":[]}".length())); + } + + @Test + void postReturns405WithAllowHeader() throws IOException { + HttpExchange ex = newExchange("POST"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + + Handlers.healthHandler(() -> new HealthOutcome("Up", List.of())).handle(ex); + + verify(ex).sendResponseHeaders(HTTP_BAD_METHOD, -1); + assertThat(headers.getFirst("Allow")).isEqualTo("GET, HEAD"); + } + + @Test + void runtimeExceptionFromProbeMapsToDown503() throws IOException { + HttpExchange ex = newExchange("GET"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); + + Supplier failing = + () -> { + throw new IllegalStateException("boom"); + }; + Handlers.healthHandler(failing).handle(ex); + + verify(ex).sendResponseHeaders(eq(HTTP_UNAVAILABLE), eq((long) body.size())); + assertThat(body.toString()).isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}"); + } + + private static HttpExchange newExchange(String method) { + HttpExchange ex = mock(HttpExchange.class); + when(ex.getRequestMethod()).thenReturn(method); + when(ex.getResponseHeaders()).thenReturn(new Headers()); + return ex; + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mvn test -Dtest=HealthHandlerTest` +Expected: compilation failure ("cannot find symbol: method healthHandler"). + +- [ ] **Step 3: Implement `healthHandler`** + +Edit `src/main/java/com/retailsvc/http/Handlers.java`. Add the new static imports next to the existing ones: + +```java +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_UNAVAILABLE; +``` + +Add the new package imports: + +```java +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; +``` + +Append the new method to the body of the `Handlers` class, right after `aliveHandler()`: + +```java + /** + * Health endpoint handler. Accepts GET and HEAD; returns 200 with {@code application/json} body + * when the supplied probe reports {@code "Up"} (case-insensitive), and 503 with the same body + * shape otherwise. A probe that throws a {@link RuntimeException} is mapped to a {@code "Down"} + * outcome with an empty dependency list (and 503); the exception is never propagated to the + * default exception handler. + * + * @param probe supplier of the current {@link HealthOutcome}; must not return {@code null} + */ + public static HttpHandler healthHandler(Supplier probe) { + Objects.requireNonNull(probe, "probe"); + return new MethodLimitedHandler( + exchange -> { + try (exchange) { + HealthOutcome outcome; + try { + outcome = probe.get(); + } catch (RuntimeException e) { + LOG.warn("Health probe threw", e); + outcome = new HealthOutcome("Down", List.of()); + } + byte[] body = HealthRenderer.toJson(outcome).getBytes(UTF_8); + int status = outcome.isUp() ? HTTP_OK : HTTP_UNAVAILABLE; + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + } + }); + } +``` + +Also add the `HealthRenderer` import: + +```java +import com.retailsvc.http.internal.HealthRenderer; +``` + +- [ ] **Step 4: Run the new tests** + +Run: `mvn test -Dtest=HealthHandlerTest` +Expected: PASS (6 tests). + +- [ ] **Step 5: Run the full unit-test suite to catch regressions** + +Run: `mvn test` +Expected: BUILD SUCCESS; no failures, no errors. Existing `HandlersTest` should be unaffected. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/com/retailsvc/http/Handlers.java \ + src/test/java/com/retailsvc/http/HealthHandlerTest.java +git commit --no-verify -m "feat: add Handlers.healthHandler" +``` + +--- + +### Task 5: Pre-push verification (SonarLint + full verify) + +**Files:** none modified — verification only. + +- [ ] **Step 1: Run `mvn verify` (unit + integration tests + JaCoCo)** + +Run: `mvn verify` +Expected: BUILD SUCCESS. New classes appear in `target/site/jacoco/com.retailsvc.http/` and `…/internal/`. New code coverage should be at or near 100% (all paths covered by tests above). + +- [ ] **Step 2: Run SonarLint against every touched file** + +Per the project's pre-push convention (see `MEMORY.md` → "Check AND fix Sonar before pushing"), analyse: + +- `src/main/java/com/retailsvc/http/Handlers.java` +- `src/main/java/com/retailsvc/http/HealthOutcome.java` +- `src/main/java/com/retailsvc/http/Dependency.java` +- `src/main/java/com/retailsvc/http/internal/HealthRenderer.java` +- `src/test/java/com/retailsvc/http/HealthHandlerTest.java` +- `src/test/java/com/retailsvc/http/HealthOutcomeTest.java` +- `src/test/java/com/retailsvc/http/DependencyTest.java` +- `src/test/java/com/retailsvc/http/internal/HealthRendererTest.java` + +Use the SonarLint MCP tool (`mcp__sonarlint__sonar_analyze_file`) once per file. Fix any new issues in the same branch before pushing; commit fixes as a follow-up commit (`fix: address Sonar findings on health handler`). + +- [ ] **Step 3: Push the branch** + +```bash +git push -u origin worktree-feat+health-handling +``` + +The gh CLI cannot create PRs in this repo (see `MEMORY.md`); after the push, hand off to the user to open the PR manually. + +--- + +## Self-review + +**Spec coverage:** + +| Spec requirement | Task | +|---|---| +| `Handlers.healthHandler(Supplier)` exists with GET/HEAD only | 4 | +| 200 + `application/json` when `Up` | 4 | +| 503 with same body shape when not `Up` | 4 | +| Throwing probe → `Down` + empty deps + 503, never 500 | 4 | +| `HealthOutcome` public record with `isUp()` case-insensitive | 2 | +| `Dependency` public record | 1 | +| `HealthOutcome` defensively copies the dependency list | 2 | +| `HealthOutcome` accepts `null` deps as empty list | 2 | +| `HealthRenderer` package-private under `internal`, hand-rolled, escapes `\\ \" \n \r \t \b \f` and `\uXXXX` for `<0x20` | 3 | +| `null` return from `Supplier` propagates as 500 (intentional) | Covered indirectly: NPE thrown by `HealthRenderer.toJson(null)` → falls outside the `RuntimeException` catch is FALSE — `NullPointerException` extends `RuntimeException` and **would** be caught. See note below. | + +**Note on `null` return:** The spec says a `null` return "propagates a 500". But the handler's `try { outcome = probe.get(); } catch (RuntimeException e)` block catches `NullPointerException` too (since NPE extends RuntimeException), so a `null` return is silently mapped to `Down`+503, **not** a 500. Two ways forward: + +1. Accept the actual behavior and update the spec wording — `null` return = treated identically to a throwing probe (`Down`+503). This is arguably nicer behavior anyway: health endpoints should never 500. +2. Explicitly null-check the probe result before the catch and re-throw as a non-RuntimeException, or check after the try and let it propagate. + +Recommended: option 1 (spec edit, no code change). Flag this to the user at execution time before writing the test for the null case. The plan above intentionally does NOT include a test for the `null`-return case; resolve the spec ambiguity first. + +**Type consistency:** `HealthOutcome`, `Dependency`, `HealthRenderer.toJson(HealthOutcome)`, `Handlers.healthHandler(Supplier)` are consistent across all tasks. Field names (`outcome`, `dependencies`, `id`, `status`) match the spec's wire format. + +**Placeholder scan:** No TBDs, no "implement later", every code step shows the actual code. From 9f4a2c97ec3ccf43d6e726cd395953d50ad4a3d7 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 08:50:36 +0200 Subject: [PATCH 04/14] docs: Use SKIP=commitlint and capitalised subjects in plan --- docs/superpowers/plans/2026-05-20-health-handler.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-05-20-health-handler.md b/docs/superpowers/plans/2026-05-20-health-handler.md index fd78e67..a722497 100644 --- a/docs/superpowers/plans/2026-05-20-health-handler.md +++ b/docs/superpowers/plans/2026-05-20-health-handler.md @@ -110,7 +110,7 @@ Expected: PASS (3 tests). ```bash git add src/main/java/com/retailsvc/http/Dependency.java \ src/test/java/com/retailsvc/http/DependencyTest.java -git commit --no-verify -m "feat: add Dependency record for health responses" +SKIP=commitlint git commit -m "feat: Add Dependency record for health responses" ``` --- @@ -227,7 +227,7 @@ Expected: PASS (6 tests). ```bash git add src/main/java/com/retailsvc/http/HealthOutcome.java \ src/test/java/com/retailsvc/http/HealthOutcomeTest.java -git commit --no-verify -m "feat: add HealthOutcome record for health responses" +SKIP=commitlint git commit -m "feat: Add HealthOutcome record for health responses" ``` --- @@ -394,7 +394,7 @@ Expected: PASS (4 tests). ```bash git add src/main/java/com/retailsvc/http/internal/HealthRenderer.java \ src/test/java/com/retailsvc/http/internal/HealthRendererTest.java -git commit --no-verify -m "feat: add HealthRenderer for health-response JSON" +SKIP=commitlint git commit -m "feat: Add HealthRenderer for health-response JSON" ``` --- @@ -608,7 +608,7 @@ Expected: BUILD SUCCESS; no failures, no errors. Existing `HandlersTest` should ```bash git add src/main/java/com/retailsvc/http/Handlers.java \ src/test/java/com/retailsvc/http/HealthHandlerTest.java -git commit --no-verify -m "feat: add Handlers.healthHandler" +SKIP=commitlint git commit -m "feat: Add Handlers.healthHandler" ``` --- @@ -635,7 +635,7 @@ Per the project's pre-push convention (see `MEMORY.md` → "Check AND fix Sonar - `src/test/java/com/retailsvc/http/DependencyTest.java` - `src/test/java/com/retailsvc/http/internal/HealthRendererTest.java` -Use the SonarLint MCP tool (`mcp__sonarlint__sonar_analyze_file`) once per file. Fix any new issues in the same branch before pushing; commit fixes as a follow-up commit (`fix: address Sonar findings on health handler`). +Use the SonarLint MCP tool (`mcp__sonarlint__sonar_analyze_file`) once per file. Fix any new issues in the same branch before pushing; commit fixes as a follow-up commit (`fix: Address Sonar findings on health handler`). - [ ] **Step 3: Push the branch** From 780a529c9a9edf6eae9b47c1b6328d63019ab992 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 09:43:06 +0200 Subject: [PATCH 05/14] docs: Treat null probe return as Down+503 --- .../plans/2026-05-20-health-handler.md | 33 ++++++++++++------- .../specs/2026-05-20-health-handler-design.md | 19 ++++++----- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/plans/2026-05-20-health-handler.md b/docs/superpowers/plans/2026-05-20-health-handler.md index a722497..6d8e048 100644 --- a/docs/superpowers/plans/2026-05-20-health-handler.md +++ b/docs/superpowers/plans/2026-05-20-health-handler.md @@ -522,6 +522,20 @@ class HealthHandlerTest { assertThat(body.toString()).isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}"); } + @Test + void nullReturnFromProbeMapsToDown503() throws IOException { + HttpExchange ex = newExchange("GET"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); + + Handlers.healthHandler(() -> null).handle(ex); + + verify(ex).sendResponseHeaders(eq(HTTP_UNAVAILABLE), eq((long) body.size())); + assertThat(body.toString()).isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}"); + } + private static HttpExchange newExchange(String method) { HttpExchange ex = mock(HttpExchange.class); when(ex.getRequestMethod()).thenReturn(method); @@ -572,9 +586,13 @@ Append the new method to the body of the `Handlers` class, right after `aliveHan try (exchange) { HealthOutcome outcome; try { - outcome = probe.get(); + HealthOutcome result = probe.get(); + if (result == null) { + throw new IllegalStateException("Health probe returned null"); + } + outcome = result; } catch (RuntimeException e) { - LOG.warn("Health probe threw", e); + LOG.warn("Health probe failed", e); outcome = new HealthOutcome("Down", List.of()); } byte[] body = HealthRenderer.toJson(outcome).getBytes(UTF_8); @@ -596,7 +614,7 @@ import com.retailsvc.http.internal.HealthRenderer; - [ ] **Step 4: Run the new tests** Run: `mvn test -Dtest=HealthHandlerTest` -Expected: PASS (6 tests). +Expected: PASS (7 tests). - [ ] **Step 5: Run the full unit-test suite to catch regressions** @@ -662,14 +680,7 @@ The gh CLI cannot create PRs in this repo (see `MEMORY.md`); after the push, han | `HealthOutcome` defensively copies the dependency list | 2 | | `HealthOutcome` accepts `null` deps as empty list | 2 | | `HealthRenderer` package-private under `internal`, hand-rolled, escapes `\\ \" \n \r \t \b \f` and `\uXXXX` for `<0x20` | 3 | -| `null` return from `Supplier` propagates as 500 (intentional) | Covered indirectly: NPE thrown by `HealthRenderer.toJson(null)` → falls outside the `RuntimeException` catch is FALSE — `NullPointerException` extends `RuntimeException` and **would** be caught. See note below. | - -**Note on `null` return:** The spec says a `null` return "propagates a 500". But the handler's `try { outcome = probe.get(); } catch (RuntimeException e)` block catches `NullPointerException` too (since NPE extends RuntimeException), so a `null` return is silently mapped to `Down`+503, **not** a 500. Two ways forward: - -1. Accept the actual behavior and update the spec wording — `null` return = treated identically to a throwing probe (`Down`+503). This is arguably nicer behavior anyway: health endpoints should never 500. -2. Explicitly null-check the probe result before the catch and re-throw as a non-RuntimeException, or check after the try and let it propagate. - -Recommended: option 1 (spec edit, no code change). Flag this to the user at execution time before writing the test for the null case. The plan above intentionally does NOT include a test for the `null`-return case; resolve the spec ambiguity first. +| `null` return from `Supplier` → `Down`+503 (same as throwing probe) | 4 (explicit null check inside try; `nullReturnFromProbeMapsToDown503` test) | **Type consistency:** `HealthOutcome`, `Dependency`, `HealthRenderer.toJson(HealthOutcome)`, `Handlers.healthHandler(Supplier)` are consistent across all tasks. Field names (`outcome`, `dependencies`, `id`, `status`) match the spec's wire format. diff --git a/docs/superpowers/specs/2026-05-20-health-handler-design.md b/docs/superpowers/specs/2026-05-20-health-handler-design.md index 0d32b8b..83fa558 100644 --- a/docs/superpowers/specs/2026-05-20-health-handler-design.md +++ b/docs/superpowers/specs/2026-05-20-health-handler-design.md @@ -137,15 +137,18 @@ exactly where any third-party integration belongs. ## Error handling -- `Supplier` returns `null`: `Objects.requireNonNull` inside the handler - produces an NPE; this falls outside the `RuntimeException` catch and - propagates up through `ExceptionFilter` (yielding a 500). Probes are - expected to return a value; treating a `null` return as a programming - error is intentional. +Health endpoints should never 500 — load balancers and orchestrators interpret +5xx-from-health-probe as "treat instance as unhealthy" only some of the time; +a 503 with a `Down` body is the unambiguous signal. The handler therefore +funnels every probe failure into the same `Down`+503 path: + - `Supplier` throws `RuntimeException`: caught; logged at `warn`; rendered as `Down` with empty dependency list and 503. +- `Supplier` returns `null`: treated identically to a throwing probe — + logged at `warn` and rendered as `Down`+503. (The handler asserts + non-null via an explicit check inside the same `try` block.) - IOException while writing the response: not caught here; `ExceptionFilter` - handles it. + handles it (this is a transport-level failure, not a probe failure). ## Testing @@ -161,8 +164,8 @@ Unit tests only (Surefire). New file `HealthHandlerTest` (or extension of - HEAD → status code only, no body bytes. - POST → 405 with `Allow: GET, HEAD` header. - Probe throws `RuntimeException` → 503, body `{"outcome":"Down","dependencies":[]}`. -- Probe returns `null` → propagates (assertion: 500 via the default - exception handler). +- Probe returns `null` → 503, body `{"outcome":"Down","dependencies":[]}` + (same behaviour as a throwing probe). New file `HealthRendererTest`: From 52f5b56b1d7ba9a0937241d029c61f01a1f80b6e Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 09:46:02 +0200 Subject: [PATCH 06/14] feat: Add Dependency record for health responses --- .../java/com/retailsvc/http/Dependency.java | 17 +++++++++++ .../com/retailsvc/http/DependencyTest.java | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 src/main/java/com/retailsvc/http/Dependency.java create mode 100644 src/test/java/com/retailsvc/http/DependencyTest.java diff --git a/src/main/java/com/retailsvc/http/Dependency.java b/src/main/java/com/retailsvc/http/Dependency.java new file mode 100644 index 0000000..8366b5c --- /dev/null +++ b/src/main/java/com/retailsvc/http/Dependency.java @@ -0,0 +1,17 @@ +package com.retailsvc.http; + +import java.util.Objects; + +/** + * A single dependency entry within a {@link HealthOutcome}. + * + * @param id stable identifier of the dependency (e.g. {@code "jdbc"}) + * @param status free-form status; {@code "Up"} (case-insensitive) is treated as healthy by {@link + * HealthOutcome#isUp()}; any other value is treated as unhealthy + */ +public record Dependency(String id, String status) { + public Dependency { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(status, "status"); + } +} diff --git a/src/test/java/com/retailsvc/http/DependencyTest.java b/src/test/java/com/retailsvc/http/DependencyTest.java new file mode 100644 index 0000000..47a626d --- /dev/null +++ b/src/test/java/com/retailsvc/http/DependencyTest.java @@ -0,0 +1,30 @@ +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import org.junit.jupiter.api.Test; + +class DependencyTest { + + @Test + void holdsIdAndStatus() { + Dependency d = new Dependency("jdbc", "Up"); + assertThat(d.id()).isEqualTo("jdbc"); + assertThat(d.status()).isEqualTo("Up"); + } + + @Test + void rejectsNullId() { + assertThatNullPointerException() + .isThrownBy(() -> new Dependency(null, "Up")) + .withMessageContaining("id"); + } + + @Test + void rejectsNullStatus() { + assertThatNullPointerException() + .isThrownBy(() -> new Dependency("jdbc", null)) + .withMessageContaining("status"); + } +} From 5fe3b7d503b6700fb9ee0028efcf9e9a4b501055 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 09:49:53 +0200 Subject: [PATCH 07/14] feat: Add HealthOutcome record for health responses --- .../com/retailsvc/http/HealthOutcome.java | 27 ++++++++++ .../com/retailsvc/http/HealthOutcomeTest.java | 54 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/main/java/com/retailsvc/http/HealthOutcome.java create mode 100644 src/test/java/com/retailsvc/http/HealthOutcomeTest.java diff --git a/src/main/java/com/retailsvc/http/HealthOutcome.java b/src/main/java/com/retailsvc/http/HealthOutcome.java new file mode 100644 index 0000000..6ef8bd8 --- /dev/null +++ b/src/main/java/com/retailsvc/http/HealthOutcome.java @@ -0,0 +1,27 @@ +package com.retailsvc.http; + +import java.util.List; +import java.util.Objects; + +/** + * Wire-shape carrier for the {@link Handlers#healthHandler health handler} response. + * + *

The record owns the JSON shape on the wire — {@code {"outcome": "...", "dependencies": [ + * {"id": "...", "status": "..."} ]}}. Construct it from whatever check-running mechanism the caller + * prefers; this library has no opinion. + * + * @param outcome overall outcome; {@code "Up"} (case-insensitive) means healthy + * @param dependencies per-dependency statuses; {@code null} is normalised to an empty list + */ +public record HealthOutcome(String outcome, List dependencies) { + + public HealthOutcome { + Objects.requireNonNull(outcome, "outcome"); + dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of())); + } + + /** Returns {@code true} when {@link #outcome()} equals {@code "Up"} ignoring case. */ + public boolean isUp() { + return "Up".equalsIgnoreCase(outcome); + } +} diff --git a/src/test/java/com/retailsvc/http/HealthOutcomeTest.java b/src/test/java/com/retailsvc/http/HealthOutcomeTest.java new file mode 100644 index 0000000..dea1649 --- /dev/null +++ b/src/test/java/com/retailsvc/http/HealthOutcomeTest.java @@ -0,0 +1,54 @@ +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class HealthOutcomeTest { + + @Test + void exposesOutcomeAndDependencies() { + HealthOutcome o = new HealthOutcome("Up", List.of(new Dependency("jdbc", "Up"))); + assertThat(o.outcome()).isEqualTo("Up"); + assertThat(o.dependencies()).containsExactly(new Dependency("jdbc", "Up")); + } + + @Test + void rejectsNullOutcome() { + assertThatNullPointerException() + .isThrownBy(() -> new HealthOutcome(null, List.of())) + .withMessageContaining("outcome"); + } + + @Test + void coercesNullDependenciesToEmpty() { + HealthOutcome o = new HealthOutcome("Up", null); + assertThat(o.dependencies()).isEmpty(); + } + + @Test + void copiesDependencyListDefensively() { + List mutable = new ArrayList<>(); + mutable.add(new Dependency("jdbc", "Up")); + HealthOutcome o = new HealthOutcome("Up", mutable); + mutable.clear(); + assertThat(o.dependencies()).hasSize(1); + } + + @Test + void isUpReturnsTrueForUpCaseInsensitive() { + assertThat(new HealthOutcome("Up", List.of()).isUp()).isTrue(); + assertThat(new HealthOutcome("UP", List.of()).isUp()).isTrue(); + assertThat(new HealthOutcome("up", List.of()).isUp()).isTrue(); + } + + @Test + void isUpReturnsFalseForAnythingElse() { + assertThat(new HealthOutcome("Down", List.of()).isUp()).isFalse(); + assertThat(new HealthOutcome("", List.of()).isUp()).isFalse(); + assertThat(new HealthOutcome("Degraded", List.of()).isUp()).isFalse(); + } +} From 3e09075a5018b7a3394400ea03fb2695e56322eb Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 09:53:03 +0200 Subject: [PATCH 08/14] feat: Add HealthRenderer for health-response JSON --- .../http/internal/HealthRenderer.java | 76 +++++++++++++++++++ .../http/internal/HealthRendererTest.java | 53 +++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/main/java/com/retailsvc/http/internal/HealthRenderer.java create mode 100644 src/test/java/com/retailsvc/http/internal/HealthRendererTest.java 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..061491c --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/HealthRenderer.java @@ -0,0 +1,76 @@ +package com.retailsvc.http.internal; + +import com.retailsvc.http.Dependency; +import com.retailsvc.http.HealthOutcome; +import java.util.List; + +/** + * Hand-rolled JSON renderer for {@link HealthOutcome} responses. + * + *

Mirrors {@link ProblemDetailRenderer} — the library avoids pulling in a JSON writer for a + * handful of fixed fields with known shapes. + */ +public final class HealthRenderer { + + /** Initial capacity sized for a typical health document with a handful of dependencies. */ + private static final int INITIAL_BUFFER_CAPACITY = 128; + + /** Codepoints below this value are control characters and must be unicode-escaped in JSON. */ + private static final int FIRST_PRINTABLE_ASCII = 0x20; + + private HealthRenderer() {} + + public static String toJson(HealthOutcome outcome) { + StringBuilder out = new StringBuilder(INITIAL_BUFFER_CAPACITY); + out.append('{'); + appendStringField(out, "outcome", outcome.outcome()); + out.append(",\"dependencies\":["); + appendDependencies(out, outcome.dependencies()); + out.append("]}"); + return out.toString(); + } + + private static void appendDependencies(StringBuilder out, List deps) { + for (int i = 0; i < deps.size(); i++) { + if (i > 0) { + out.append(','); + } + Dependency d = deps.get(i); + out.append('{'); + appendStringField(out, "id", d.id()); + out.append(','); + appendStringField(out, "status", d.status()); + out.append('}'); + } + } + + private static void appendStringField(StringBuilder out, String name, String value) { + out.append('"').append(name).append("\":\""); + appendEscaped(out, value); + out.append('"'); + } + + private static void appendEscaped(StringBuilder out, String value) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '\\' -> out.append("\\\\"); + case '"' -> out.append("\\\""); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + case '\b' -> out.append("\\b"); + case '\f' -> out.append("\\f"); + default -> appendUnicodeOrLiteral(out, c); + } + } + } + + private static void appendUnicodeOrLiteral(StringBuilder out, char c) { + if (c < FIRST_PRINTABLE_ASCII) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } +} 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..0e798c7 --- /dev/null +++ b/src/test/java/com/retailsvc/http/internal/HealthRendererTest.java @@ -0,0 +1,53 @@ +package com.retailsvc.http.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.retailsvc.http.Dependency; +import com.retailsvc.http.HealthOutcome; +import java.util.List; +import org.junit.jupiter.api.Test; + +class HealthRendererTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void rendersOutcomeAndEmptyDependencies() { + String json = HealthRenderer.toJson(new HealthOutcome("Up", List.of())); + assertThat(json).isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[]}"); + } + + @Test + void rendersOutcomeAndDependencies() throws Exception { + String json = + HealthRenderer.toJson( + new HealthOutcome( + "Down", List.of(new Dependency("jdbc", "Down"), new Dependency("cache", "Up")))); + + JsonNode root = MAPPER.readTree(json); + assertThat(root.get("outcome").asText()).isEqualTo("Down"); + assertThat(root.get("dependencies")).hasSize(2); + assertThat(root.get("dependencies").get(0).get("id").asText()).isEqualTo("jdbc"); + assertThat(root.get("dependencies").get(0).get("status").asText()).isEqualTo("Down"); + assertThat(root.get("dependencies").get(1).get("id").asText()).isEqualTo("cache"); + assertThat(root.get("dependencies").get(1).get("status").asText()).isEqualTo("Up"); + } + + @Test + void escapesQuotesAndBackslashes() throws Exception { + String json = + HealthRenderer.toJson(new HealthOutcome("Up", List.of(new Dependency("a\"b\\c", "Up")))); + JsonNode root = MAPPER.readTree(json); + assertThat(root.get("dependencies").get(0).get("id").asText()).isEqualTo("a\"b\\c"); + } + + @Test + void escapesControlCharacters() throws Exception { + String id = "tab\there\nnextend"; + String json = HealthRenderer.toJson(new HealthOutcome("Up", List.of(new Dependency(id, "Up")))); + JsonNode root = MAPPER.readTree(json); + assertThat(root.get("dependencies").get(0).get("id").asText()).isEqualTo(id); + } +} From cbc58bd0ca2dbfc27577542e94c838756ed7aa11 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 10:05:30 +0200 Subject: [PATCH 09/14] feat: Add Handlers.healthHandler --- .../java/com/retailsvc/http/Handlers.java | 40 ++++++ .../com/retailsvc/http/HealthHandlerTest.java | 133 ++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/test/java/com/retailsvc/http/HealthHandlerTest.java diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index 45ad17b..ef6ca17 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -5,13 +5,19 @@ import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; import static java.net.HttpURLConnection.HTTP_NOT_FOUND; import static java.net.HttpURLConnection.HTTP_NO_CONTENT; +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.MethodLimitedHandler; import com.retailsvc.http.internal.ProblemDetailRenderer; import com.sun.net.httpserver.HttpHandler; import java.io.IOException; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -67,6 +73,40 @@ public static HttpHandler aliveHandler() { }); } + /** + * Health endpoint handler. Accepts GET and HEAD; returns 200 with {@code application/json} body + * when the supplied probe reports {@code "Up"} (case-insensitive), 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. + * + * @param probe supplier of the current {@link HealthOutcome} + */ + public static HttpHandler healthHandler(Supplier probe) { + Objects.requireNonNull(probe, "probe"); + return new MethodLimitedHandler( + exchange -> { + try (exchange) { + HealthOutcome outcome; + try { + HealthOutcome result = probe.get(); + if (result == null) { + throw new IllegalStateException("Health probe returned null"); + } + outcome = result; + } catch (RuntimeException e) { + LOG.warn("Health probe failed", e); + outcome = new HealthOutcome("Down", List.of()); + } + byte[] body = HealthRenderer.toJson(outcome).getBytes(UTF_8); + int status = outcome.isUp() ? HTTP_OK : HTTP_UNAVAILABLE; + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + } + }); + } + /** * 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/test/java/com/retailsvc/http/HealthHandlerTest.java b/src/test/java/com/retailsvc/http/HealthHandlerTest.java new file mode 100644 index 0000000..80ac468 --- /dev/null +++ b/src/test/java/com/retailsvc/http/HealthHandlerTest.java @@ -0,0 +1,133 @@ +package com.retailsvc.http; + +import static java.net.HttpURLConnection.HTTP_BAD_METHOD; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_UNAVAILABLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class HealthHandlerTest { + + @Test + void getReturns200AndJsonBodyWhenUp() throws IOException { + HealthOutcome outcome = new HealthOutcome("Up", List.of(new Dependency("jdbc", "Up"))); + HttpExchange ex = newExchange("GET"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); + + Handlers.healthHandler(() -> outcome).handle(ex); + + verify(ex).sendResponseHeaders(eq(HTTP_OK), eq((long) body.size())); + assertThat(headers.getFirst("Content-Type")).isEqualTo("application/json"); + assertThat(body.toString()) + .isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"jdbc\",\"status\":\"Up\"}]}"); + } + + @Test + void getReturns200WithEmptyDependencyArrayWhenNoDeps() throws IOException { + HttpExchange ex = newExchange("GET"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); + + Handlers.healthHandler(() -> new HealthOutcome("Up", List.of())).handle(ex); + + verify(ex).sendResponseHeaders(eq(HTTP_OK), eq((long) body.size())); + assertThat(body.toString()).isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[]}"); + } + + @Test + void getReturns503WhenDown() throws IOException { + HealthOutcome outcome = new HealthOutcome("Down", List.of(new Dependency("jdbc", "Down"))); + HttpExchange ex = newExchange("GET"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); + + Handlers.healthHandler(() -> outcome).handle(ex); + + verify(ex).sendResponseHeaders(eq(HTTP_UNAVAILABLE), eq((long) body.size())); + assertThat(headers.getFirst("Content-Type")).isEqualTo("application/json"); + assertThat(body.toString()).contains("\"outcome\":\"Down\""); + } + + @Test + void headIsAccepted() throws IOException { + HttpExchange ex = newExchange("HEAD"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + when(ex.getResponseBody()).thenReturn(new ByteArrayOutputStream()); + + Handlers.healthHandler(() -> new HealthOutcome("Up", List.of())).handle(ex); + + verify(ex) + .sendResponseHeaders( + eq(HTTP_OK), eq((long) "{\"outcome\":\"Up\",\"dependencies\":[]}".length())); + } + + @Test + void postReturns405WithAllowHeader() throws IOException { + HttpExchange ex = newExchange("POST"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + + Handlers.healthHandler(() -> new HealthOutcome("Up", List.of())).handle(ex); + + verify(ex).sendResponseHeaders(HTTP_BAD_METHOD, -1); + assertThat(headers.getFirst("Allow")).isEqualTo("GET, HEAD"); + } + + @Test + void runtimeExceptionFromProbeMapsToDown503() throws IOException { + HttpExchange ex = newExchange("GET"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); + + Supplier failing = + () -> { + throw new IllegalStateException("boom"); + }; + Handlers.healthHandler(failing).handle(ex); + + verify(ex).sendResponseHeaders(eq(HTTP_UNAVAILABLE), eq((long) body.size())); + assertThat(body.toString()).isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}"); + } + + @Test + void nullReturnFromProbeMapsToDown503() throws IOException { + HttpExchange ex = newExchange("GET"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); + + Handlers.healthHandler(() -> null).handle(ex); + + verify(ex).sendResponseHeaders(eq(HTTP_UNAVAILABLE), eq((long) body.size())); + assertThat(body.toString()).isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}"); + } + + private static HttpExchange newExchange(String method) { + HttpExchange ex = mock(HttpExchange.class); + when(ex.getRequestMethod()).thenReturn(method); + when(ex.getResponseHeaders()).thenReturn(new Headers()); + return ex; + } +} From a63321fa5882414d24f0bb73eb94126ed0a4fb9a Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 10:08:22 +0200 Subject: [PATCH 10/14] refactor: Simplify health-probe null handling --- src/main/java/com/retailsvc/http/Handlers.java | 6 +----- src/test/java/com/retailsvc/http/HealthHandlerTest.java | 7 +++---- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index ef6ca17..0e9aea5 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -89,11 +89,7 @@ public static HttpHandler healthHandler(Supplier probe) { try (exchange) { HealthOutcome outcome; try { - HealthOutcome result = probe.get(); - if (result == null) { - throw new IllegalStateException("Health probe returned null"); - } - outcome = result; + outcome = Objects.requireNonNull(probe.get(), "Health probe returned null"); } catch (RuntimeException e) { LOG.warn("Health probe failed", e); outcome = new HealthOutcome("Down", List.of()); diff --git a/src/test/java/com/retailsvc/http/HealthHandlerTest.java b/src/test/java/com/retailsvc/http/HealthHandlerTest.java index 80ac468..0e5bf5e 100644 --- a/src/test/java/com/retailsvc/http/HealthHandlerTest.java +++ b/src/test/java/com/retailsvc/http/HealthHandlerTest.java @@ -71,13 +71,12 @@ void headIsAccepted() throws IOException { HttpExchange ex = newExchange("HEAD"); Headers headers = new Headers(); when(ex.getResponseHeaders()).thenReturn(headers); - when(ex.getResponseBody()).thenReturn(new ByteArrayOutputStream()); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); Handlers.healthHandler(() -> new HealthOutcome("Up", List.of())).handle(ex); - verify(ex) - .sendResponseHeaders( - eq(HTTP_OK), eq((long) "{\"outcome\":\"Up\",\"dependencies\":[]}".length())); + verify(ex).sendResponseHeaders(eq(HTTP_OK), eq((long) body.size())); } @Test From 0fee6d0be24df502b87aa86bd64b8cecf6984af8 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 10:36:58 +0200 Subject: [PATCH 11/14] refactor: Extract shared JsonStrings helper --- .../http/internal/HealthRenderer.java | 39 ++------------ .../retailsvc/http/internal/JsonStrings.java | 52 +++++++++++++++++++ .../http/internal/ProblemDetailRenderer.java | 52 +++---------------- 3 files changed, 63 insertions(+), 80 deletions(-) create mode 100644 src/main/java/com/retailsvc/http/internal/JsonStrings.java diff --git a/src/main/java/com/retailsvc/http/internal/HealthRenderer.java b/src/main/java/com/retailsvc/http/internal/HealthRenderer.java index 061491c..1cb4ca0 100644 --- a/src/main/java/com/retailsvc/http/internal/HealthRenderer.java +++ b/src/main/java/com/retailsvc/http/internal/HealthRenderer.java @@ -15,15 +15,12 @@ public final class HealthRenderer { /** Initial capacity sized for a typical health document with a handful of dependencies. */ private static final int INITIAL_BUFFER_CAPACITY = 128; - /** Codepoints below this value are control characters and must be unicode-escaped in JSON. */ - private static final int FIRST_PRINTABLE_ASCII = 0x20; - private HealthRenderer() {} public static String toJson(HealthOutcome outcome) { StringBuilder out = new StringBuilder(INITIAL_BUFFER_CAPACITY); out.append('{'); - appendStringField(out, "outcome", outcome.outcome()); + JsonStrings.appendStringField(out, "outcome", outcome.outcome()); out.append(",\"dependencies\":["); appendDependencies(out, outcome.dependencies()); out.append("]}"); @@ -37,40 +34,10 @@ private static void appendDependencies(StringBuilder out, List deps) } Dependency d = deps.get(i); out.append('{'); - appendStringField(out, "id", d.id()); + JsonStrings.appendStringField(out, "id", d.id()); out.append(','); - appendStringField(out, "status", d.status()); + JsonStrings.appendStringField(out, "status", d.status()); out.append('}'); } } - - private static void appendStringField(StringBuilder out, String name, String value) { - out.append('"').append(name).append("\":\""); - appendEscaped(out, value); - out.append('"'); - } - - private static void appendEscaped(StringBuilder out, String value) { - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - switch (c) { - case '\\' -> out.append("\\\\"); - case '"' -> out.append("\\\""); - case '\n' -> out.append("\\n"); - case '\r' -> out.append("\\r"); - case '\t' -> out.append("\\t"); - case '\b' -> out.append("\\b"); - case '\f' -> out.append("\\f"); - default -> appendUnicodeOrLiteral(out, c); - } - } - } - - private static void appendUnicodeOrLiteral(StringBuilder out, char c) { - if (c < FIRST_PRINTABLE_ASCII) { - out.append(String.format("\\u%04x", (int) c)); - } else { - out.append(c); - } - } } 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..4fd5d90 --- /dev/null +++ b/src/main/java/com/retailsvc/http/internal/JsonStrings.java @@ -0,0 +1,52 @@ +package com.retailsvc.http.internal; + +/** + * Small JSON string-writing helpers shared by the library's hand-rolled renderers. + * + *

Used in place of a full JSON writer because the library only ever emits a handful of fixed + * fields with known shapes. Package-private — not part of the public API. + */ +final class JsonStrings { + + /** Codepoints below this value are control characters and must be unicode-escaped in JSON. */ + private static final int FIRST_PRINTABLE_ASCII = 0x20; + + private JsonStrings() {} + + /** Appends {@code "name":""} to {@code out}. */ + static void appendStringField(StringBuilder out, String name, String value) { + out.append('"').append(name).append("\":\""); + appendEscaped(out, value); + out.append('"'); + } + + /** + * Appends {@code value} with JSON-string escaping applied. Handles the seven mandatory escape + * sequences ({@code \\}, {@code \"}, {@code \n}, {@code \r}, {@code \t}, {@code \b}, {@code \f}) + * and emits a {@code \uXXXX} sequence for any remaining control character below {@link + * #FIRST_PRINTABLE_ASCII}. + */ + static void appendEscaped(StringBuilder out, String value) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '\\' -> out.append("\\\\"); + case '"' -> out.append("\\\""); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + case '\b' -> out.append("\\b"); + case '\f' -> out.append("\\f"); + default -> appendUnicodeOrLiteral(out, c); + } + } + } + + private static void appendUnicodeOrLiteral(StringBuilder out, char c) { + if (c < FIRST_PRINTABLE_ASCII) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } +} diff --git a/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java b/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java index 31931d6..63cdd2e 100644 --- a/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java +++ b/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java @@ -17,21 +17,18 @@ public final class ProblemDetailRenderer { /** Initial capacity of the JSON buffer; sized for a typical problem-detail document. */ private static final int INITIAL_BUFFER_CAPACITY = 128; - /** Codepoints below this value are control characters and must be unicode-escaped in JSON. */ - private static final int FIRST_PRINTABLE_ASCII = 0x20; - private ProblemDetailRenderer() {} public static String render(int status, String title, String detail) { StringBuilder out = new StringBuilder(INITIAL_BUFFER_CAPACITY); out.append('{'); - appendStringField(out, "type", PROBLEM_TYPE); + JsonStrings.appendStringField(out, "type", PROBLEM_TYPE); out.append(','); - appendStringField(out, "title", title); + JsonStrings.appendStringField(out, "title", title); out.append(','); appendIntField(out, "status", status); out.append(','); - appendStringField(out, "detail", detail); + JsonStrings.appendStringField(out, "detail", detail); out.append('}'); return out.toString(); } @@ -39,55 +36,22 @@ public static String render(int status, String title, String detail) { public static String render(ValidationError error) { StringBuilder out = new StringBuilder(INITIAL_BUFFER_CAPACITY); out.append('{'); - appendStringField(out, "type", PROBLEM_TYPE); + JsonStrings.appendStringField(out, "type", PROBLEM_TYPE); out.append(','); - appendStringField(out, "title", PROBLEM_TITLE); + JsonStrings.appendStringField(out, "title", PROBLEM_TITLE); out.append(','); appendIntField(out, "status", PROBLEM_STATUS); out.append(','); - appendStringField(out, "detail", error.message()); + JsonStrings.appendStringField(out, "detail", error.message()); out.append(','); - appendStringField(out, "pointer", error.pointer()); + JsonStrings.appendStringField(out, "pointer", error.pointer()); out.append(','); - appendStringField(out, "keyword", error.keyword()); + JsonStrings.appendStringField(out, "keyword", error.keyword()); out.append('}'); return out.toString(); } - private static void appendStringField(StringBuilder out, String name, String value) { - out.append('"').append(name).append("\":\""); - appendEscaped(out, value); - out.append('"'); - } - private static void appendIntField(StringBuilder out, String name, int value) { out.append('"').append(name).append("\":").append(value); } - - /** - * Appends {@code value} to {@code out} with JSON-string escaping applied. Handles the six - * mandatory escape sequences and emits {@code \uXXXX} for control characters below {@link - * #FIRST_PRINTABLE_ASCII}. - */ - private static void appendEscaped(StringBuilder out, String value) { - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - switch (c) { - case '\\' -> out.append("\\\\"); - case '"' -> out.append("\\\""); - case '\n' -> out.append("\\n"); - case '\r' -> out.append("\\r"); - case '\t' -> out.append("\\t"); - default -> appendUnicodeOrLiteral(out, c); - } - } - } - - private static void appendUnicodeOrLiteral(StringBuilder out, char c) { - if (c < FIRST_PRINTABLE_ASCII) { - out.append(String.format("\\u%04x", (int) c)); - } else { - out.append(c); - } - } } From 9cf75842b3d93151c27b341176a181509620b8e6 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 10:58:31 +0200 Subject: [PATCH 12/14] refactor: Use TypeMapper for health serialisation and boolean outcome --- .../java/com/retailsvc/http/Dependency.java | 9 ++-- .../java/com/retailsvc/http/Handlers.java | 44 +++++++++++---- .../com/retailsvc/http/HealthOutcome.java | 19 +++---- .../http/internal/HealthRenderer.java | 43 --------------- .../retailsvc/http/internal/JsonStrings.java | 52 ------------------ .../http/internal/ProblemDetailRenderer.java | 52 +++++++++++++++--- .../com/retailsvc/http/DependencyTest.java | 15 ++---- .../com/retailsvc/http/HealthHandlerTest.java | 25 +++++---- .../com/retailsvc/http/HealthOutcomeTest.java | 36 +++---------- .../http/internal/HealthRendererTest.java | 53 ------------------- 10 files changed, 117 insertions(+), 231 deletions(-) delete mode 100644 src/main/java/com/retailsvc/http/internal/HealthRenderer.java delete mode 100644 src/main/java/com/retailsvc/http/internal/JsonStrings.java delete mode 100644 src/test/java/com/retailsvc/http/internal/HealthRendererTest.java diff --git a/src/main/java/com/retailsvc/http/Dependency.java b/src/main/java/com/retailsvc/http/Dependency.java index 8366b5c..54458f3 100644 --- a/src/main/java/com/retailsvc/http/Dependency.java +++ b/src/main/java/com/retailsvc/http/Dependency.java @@ -5,13 +5,14 @@ /** * A single dependency entry within a {@link HealthOutcome}. * + *

The library translates {@code up} into the wire value {@code "Up"} or {@code "Down"} for the + * {@code status} field. + * * @param id stable identifier of the dependency (e.g. {@code "jdbc"}) - * @param status free-form status; {@code "Up"} (case-insensitive) is treated as healthy by {@link - * HealthOutcome#isUp()}; any other value is treated as unhealthy + * @param up whether the dependency is healthy */ -public record Dependency(String id, String status) { +public record Dependency(String id, boolean up) { public Dependency { Objects.requireNonNull(id, "id"); - Objects.requireNonNull(status, "status"); } } diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index 0e9aea5..582da93 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -10,12 +10,12 @@ 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.MethodLimitedHandler; import com.retailsvc.http.internal.ProblemDetailRenderer; import com.sun.net.httpserver.HttpHandler; import java.io.IOException; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -75,14 +75,27 @@ public static HttpHandler aliveHandler() { /** * Health endpoint handler. Accepts GET and HEAD; returns 200 with {@code application/json} body - * when the supplied probe reports {@code "Up"} (case-insensitive), 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 {@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. * + *

The wire shape is + * + *

{@code
+   * {"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 {@code Map} matching the shape above; any standard JSON library + * (Gson, Jackson, …) serialises it identically. + * + * @param jsonMapper used to encode the wire-shape {@code Map} to bytes * @param probe supplier of the current {@link HealthOutcome} */ - public static HttpHandler healthHandler(Supplier probe) { + public static HttpHandler healthHandler(TypeMapper jsonMapper, Supplier probe) { + Objects.requireNonNull(jsonMapper, "jsonMapper"); Objects.requireNonNull(probe, "probe"); return new MethodLimitedHandler( exchange -> { @@ -92,10 +105,10 @@ public static HttpHandler healthHandler(Supplier probe) { outcome = Objects.requireNonNull(probe.get(), "Health probe returned null"); } catch (RuntimeException e) { LOG.warn("Health probe failed", e); - outcome = new HealthOutcome("Down", List.of()); + outcome = new HealthOutcome(false, List.of()); } - byte[] body = HealthRenderer.toJson(outcome).getBytes(UTF_8); - int status = outcome.isUp() ? HTTP_OK : HTTP_UNAVAILABLE; + byte[] body = jsonMapper.writeTo(toWireShape(outcome)); + int status = outcome.up() ? HTTP_OK : HTTP_UNAVAILABLE; exchange.getResponseHeaders().add("Content-Type", "application/json"); exchange.sendResponseHeaders(status, body.length); exchange.getResponseBody().write(body); @@ -103,6 +116,19 @@ public static HttpHandler healthHandler(Supplier probe) { }); } + private static Map toWireShape(HealthOutcome outcome) { + return Map.of( + "outcome", label(outcome.up()), + "dependencies", + outcome.dependencies().stream() + .map(d -> Map.of("id", d.id(), "status", label(d.up()))) + .toList()); + } + + private static String label(boolean up) { + return up ? "Up" : "Down"; + } + /** * 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 6ef8bd8..8950e4f 100644 --- a/src/main/java/com/retailsvc/http/HealthOutcome.java +++ b/src/main/java/com/retailsvc/http/HealthOutcome.java @@ -4,24 +4,19 @@ import java.util.Objects; /** - * Wire-shape carrier for the {@link Handlers#healthHandler health handler} response. + * Carrier for the {@link Handlers#healthHandler health handler} response. * - *

The record owns the JSON shape on the wire — {@code {"outcome": "...", "dependencies": [ - * {"id": "...", "status": "..."} ]}}. Construct it from whatever check-running mechanism the caller - * prefers; this library has no opinion. + *

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. * - * @param outcome overall outcome; {@code "Up"} (case-insensitive) means healthy + * @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(String outcome, List dependencies) { +public record HealthOutcome(boolean up, List dependencies) { public HealthOutcome { - Objects.requireNonNull(outcome, "outcome"); dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of())); } - - /** Returns {@code true} when {@link #outcome()} equals {@code "Up"} ignoring case. */ - public boolean isUp() { - return "Up".equalsIgnoreCase(outcome); - } } diff --git a/src/main/java/com/retailsvc/http/internal/HealthRenderer.java b/src/main/java/com/retailsvc/http/internal/HealthRenderer.java deleted file mode 100644 index 1cb4ca0..0000000 --- a/src/main/java/com/retailsvc/http/internal/HealthRenderer.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.retailsvc.http.internal; - -import com.retailsvc.http.Dependency; -import com.retailsvc.http.HealthOutcome; -import java.util.List; - -/** - * Hand-rolled JSON renderer for {@link HealthOutcome} responses. - * - *

Mirrors {@link ProblemDetailRenderer} — the library avoids pulling in a JSON writer for a - * handful of fixed fields with known shapes. - */ -public final class HealthRenderer { - - /** Initial capacity sized for a typical health document with a handful of dependencies. */ - private static final int INITIAL_BUFFER_CAPACITY = 128; - - private HealthRenderer() {} - - public static String toJson(HealthOutcome outcome) { - StringBuilder out = new StringBuilder(INITIAL_BUFFER_CAPACITY); - out.append('{'); - JsonStrings.appendStringField(out, "outcome", outcome.outcome()); - out.append(",\"dependencies\":["); - appendDependencies(out, outcome.dependencies()); - out.append("]}"); - return out.toString(); - } - - private static void appendDependencies(StringBuilder out, List deps) { - for (int i = 0; i < deps.size(); i++) { - if (i > 0) { - out.append(','); - } - Dependency d = deps.get(i); - out.append('{'); - JsonStrings.appendStringField(out, "id", d.id()); - out.append(','); - JsonStrings.appendStringField(out, "status", d.status()); - out.append('}'); - } - } -} diff --git a/src/main/java/com/retailsvc/http/internal/JsonStrings.java b/src/main/java/com/retailsvc/http/internal/JsonStrings.java deleted file mode 100644 index 4fd5d90..0000000 --- a/src/main/java/com/retailsvc/http/internal/JsonStrings.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.retailsvc.http.internal; - -/** - * Small JSON string-writing helpers shared by the library's hand-rolled renderers. - * - *

Used in place of a full JSON writer because the library only ever emits a handful of fixed - * fields with known shapes. Package-private — not part of the public API. - */ -final class JsonStrings { - - /** Codepoints below this value are control characters and must be unicode-escaped in JSON. */ - private static final int FIRST_PRINTABLE_ASCII = 0x20; - - private JsonStrings() {} - - /** Appends {@code "name":""} to {@code out}. */ - static void appendStringField(StringBuilder out, String name, String value) { - out.append('"').append(name).append("\":\""); - appendEscaped(out, value); - out.append('"'); - } - - /** - * Appends {@code value} with JSON-string escaping applied. Handles the seven mandatory escape - * sequences ({@code \\}, {@code \"}, {@code \n}, {@code \r}, {@code \t}, {@code \b}, {@code \f}) - * and emits a {@code \uXXXX} sequence for any remaining control character below {@link - * #FIRST_PRINTABLE_ASCII}. - */ - static void appendEscaped(StringBuilder out, String value) { - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - switch (c) { - case '\\' -> out.append("\\\\"); - case '"' -> out.append("\\\""); - case '\n' -> out.append("\\n"); - case '\r' -> out.append("\\r"); - case '\t' -> out.append("\\t"); - case '\b' -> out.append("\\b"); - case '\f' -> out.append("\\f"); - default -> appendUnicodeOrLiteral(out, c); - } - } - } - - private static void appendUnicodeOrLiteral(StringBuilder out, char c) { - if (c < FIRST_PRINTABLE_ASCII) { - out.append(String.format("\\u%04x", (int) c)); - } else { - out.append(c); - } - } -} diff --git a/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java b/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java index 63cdd2e..31931d6 100644 --- a/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java +++ b/src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java @@ -17,18 +17,21 @@ public final class ProblemDetailRenderer { /** Initial capacity of the JSON buffer; sized for a typical problem-detail document. */ private static final int INITIAL_BUFFER_CAPACITY = 128; + /** Codepoints below this value are control characters and must be unicode-escaped in JSON. */ + private static final int FIRST_PRINTABLE_ASCII = 0x20; + private ProblemDetailRenderer() {} public static String render(int status, String title, String detail) { StringBuilder out = new StringBuilder(INITIAL_BUFFER_CAPACITY); out.append('{'); - JsonStrings.appendStringField(out, "type", PROBLEM_TYPE); + appendStringField(out, "type", PROBLEM_TYPE); out.append(','); - JsonStrings.appendStringField(out, "title", title); + appendStringField(out, "title", title); out.append(','); appendIntField(out, "status", status); out.append(','); - JsonStrings.appendStringField(out, "detail", detail); + appendStringField(out, "detail", detail); out.append('}'); return out.toString(); } @@ -36,22 +39,55 @@ public static String render(int status, String title, String detail) { public static String render(ValidationError error) { StringBuilder out = new StringBuilder(INITIAL_BUFFER_CAPACITY); out.append('{'); - JsonStrings.appendStringField(out, "type", PROBLEM_TYPE); + appendStringField(out, "type", PROBLEM_TYPE); out.append(','); - JsonStrings.appendStringField(out, "title", PROBLEM_TITLE); + appendStringField(out, "title", PROBLEM_TITLE); out.append(','); appendIntField(out, "status", PROBLEM_STATUS); out.append(','); - JsonStrings.appendStringField(out, "detail", error.message()); + appendStringField(out, "detail", error.message()); out.append(','); - JsonStrings.appendStringField(out, "pointer", error.pointer()); + appendStringField(out, "pointer", error.pointer()); out.append(','); - JsonStrings.appendStringField(out, "keyword", error.keyword()); + appendStringField(out, "keyword", error.keyword()); out.append('}'); return out.toString(); } + private static void appendStringField(StringBuilder out, String name, String value) { + out.append('"').append(name).append("\":\""); + appendEscaped(out, value); + out.append('"'); + } + private static void appendIntField(StringBuilder out, String name, int value) { out.append('"').append(name).append("\":").append(value); } + + /** + * Appends {@code value} to {@code out} with JSON-string escaping applied. Handles the six + * mandatory escape sequences and emits {@code \uXXXX} for control characters below {@link + * #FIRST_PRINTABLE_ASCII}. + */ + private static void appendEscaped(StringBuilder out, String value) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '\\' -> out.append("\\\\"); + case '"' -> out.append("\\\""); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + default -> appendUnicodeOrLiteral(out, c); + } + } + } + + private static void appendUnicodeOrLiteral(StringBuilder out, char c) { + if (c < FIRST_PRINTABLE_ASCII) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } } diff --git a/src/test/java/com/retailsvc/http/DependencyTest.java b/src/test/java/com/retailsvc/http/DependencyTest.java index 47a626d..b01ac5a 100644 --- a/src/test/java/com/retailsvc/http/DependencyTest.java +++ b/src/test/java/com/retailsvc/http/DependencyTest.java @@ -8,23 +8,16 @@ class DependencyTest { @Test - void holdsIdAndStatus() { - Dependency d = new Dependency("jdbc", "Up"); + void holdsIdAndUp() { + Dependency d = new Dependency("jdbc", true); assertThat(d.id()).isEqualTo("jdbc"); - assertThat(d.status()).isEqualTo("Up"); + assertThat(d.up()).isTrue(); } @Test void rejectsNullId() { assertThatNullPointerException() - .isThrownBy(() -> new Dependency(null, "Up")) + .isThrownBy(() -> new Dependency(null, true)) .withMessageContaining("id"); } - - @Test - void rejectsNullStatus() { - assertThatNullPointerException() - .isThrownBy(() -> new Dependency("jdbc", null)) - .withMessageContaining("status"); - } } diff --git a/src/test/java/com/retailsvc/http/HealthHandlerTest.java b/src/test/java/com/retailsvc/http/HealthHandlerTest.java index 0e5bf5e..288e3ca 100644 --- a/src/test/java/com/retailsvc/http/HealthHandlerTest.java +++ b/src/test/java/com/retailsvc/http/HealthHandlerTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.retailsvc.http.internal.gson.GsonJsonMapper; import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpExchange; import java.io.ByteArrayOutputStream; @@ -19,16 +20,18 @@ class HealthHandlerTest { + private static final TypeMapper JSON = new GsonJsonMapper(); + @Test void getReturns200AndJsonBodyWhenUp() throws IOException { - HealthOutcome outcome = new HealthOutcome("Up", List.of(new Dependency("jdbc", "Up"))); + HealthOutcome outcome = new HealthOutcome(true, List.of(new Dependency("jdbc", true))); HttpExchange ex = newExchange("GET"); Headers headers = new Headers(); when(ex.getResponseHeaders()).thenReturn(headers); ByteArrayOutputStream body = new ByteArrayOutputStream(); when(ex.getResponseBody()).thenReturn(body); - Handlers.healthHandler(() -> outcome).handle(ex); + Handlers.healthHandler(JSON, () -> outcome).handle(ex); verify(ex).sendResponseHeaders(eq(HTTP_OK), eq((long) body.size())); assertThat(headers.getFirst("Content-Type")).isEqualTo("application/json"); @@ -44,7 +47,7 @@ void getReturns200WithEmptyDependencyArrayWhenNoDeps() throws IOException { ByteArrayOutputStream body = new ByteArrayOutputStream(); when(ex.getResponseBody()).thenReturn(body); - Handlers.healthHandler(() -> new HealthOutcome("Up", List.of())).handle(ex); + Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of())).handle(ex); verify(ex).sendResponseHeaders(eq(HTTP_OK), eq((long) body.size())); assertThat(body.toString()).isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[]}"); @@ -52,18 +55,20 @@ void getReturns200WithEmptyDependencyArrayWhenNoDeps() throws IOException { @Test void getReturns503WhenDown() throws IOException { - HealthOutcome outcome = new HealthOutcome("Down", List.of(new Dependency("jdbc", "Down"))); + HealthOutcome outcome = new HealthOutcome(false, List.of(new Dependency("jdbc", false))); HttpExchange ex = newExchange("GET"); Headers headers = new Headers(); when(ex.getResponseHeaders()).thenReturn(headers); ByteArrayOutputStream body = new ByteArrayOutputStream(); when(ex.getResponseBody()).thenReturn(body); - Handlers.healthHandler(() -> outcome).handle(ex); + Handlers.healthHandler(JSON, () -> outcome).handle(ex); verify(ex).sendResponseHeaders(eq(HTTP_UNAVAILABLE), eq((long) body.size())); assertThat(headers.getFirst("Content-Type")).isEqualTo("application/json"); - assertThat(body.toString()).contains("\"outcome\":\"Down\""); + assertThat(body.toString()) + .isEqualTo( + "{\"outcome\":\"Down\",\"dependencies\":[{\"id\":\"jdbc\",\"status\":\"Down\"}]}"); } @Test @@ -74,7 +79,7 @@ void headIsAccepted() throws IOException { ByteArrayOutputStream body = new ByteArrayOutputStream(); when(ex.getResponseBody()).thenReturn(body); - Handlers.healthHandler(() -> new HealthOutcome("Up", List.of())).handle(ex); + Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of())).handle(ex); verify(ex).sendResponseHeaders(eq(HTTP_OK), eq((long) body.size())); } @@ -85,7 +90,7 @@ void postReturns405WithAllowHeader() throws IOException { Headers headers = new Headers(); when(ex.getResponseHeaders()).thenReturn(headers); - Handlers.healthHandler(() -> new HealthOutcome("Up", List.of())).handle(ex); + Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of())).handle(ex); verify(ex).sendResponseHeaders(HTTP_BAD_METHOD, -1); assertThat(headers.getFirst("Allow")).isEqualTo("GET, HEAD"); @@ -103,7 +108,7 @@ void runtimeExceptionFromProbeMapsToDown503() throws IOException { () -> { throw new IllegalStateException("boom"); }; - Handlers.healthHandler(failing).handle(ex); + Handlers.healthHandler(JSON, failing).handle(ex); verify(ex).sendResponseHeaders(eq(HTTP_UNAVAILABLE), eq((long) body.size())); assertThat(body.toString()).isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}"); @@ -117,7 +122,7 @@ void nullReturnFromProbeMapsToDown503() throws IOException { ByteArrayOutputStream body = new ByteArrayOutputStream(); when(ex.getResponseBody()).thenReturn(body); - Handlers.healthHandler(() -> null).handle(ex); + Handlers.healthHandler(JSON, () -> null).handle(ex); verify(ex).sendResponseHeaders(eq(HTTP_UNAVAILABLE), eq((long) body.size())); assertThat(body.toString()).isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}"); diff --git a/src/test/java/com/retailsvc/http/HealthOutcomeTest.java b/src/test/java/com/retailsvc/http/HealthOutcomeTest.java index dea1649..0ef6a86 100644 --- a/src/test/java/com/retailsvc/http/HealthOutcomeTest.java +++ b/src/test/java/com/retailsvc/http/HealthOutcomeTest.java @@ -1,7 +1,6 @@ package com.retailsvc.http; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatNullPointerException; import java.util.ArrayList; import java.util.List; @@ -10,45 +9,24 @@ class HealthOutcomeTest { @Test - void exposesOutcomeAndDependencies() { - HealthOutcome o = new HealthOutcome("Up", List.of(new Dependency("jdbc", "Up"))); - assertThat(o.outcome()).isEqualTo("Up"); - assertThat(o.dependencies()).containsExactly(new Dependency("jdbc", "Up")); - } - - @Test - void rejectsNullOutcome() { - assertThatNullPointerException() - .isThrownBy(() -> new HealthOutcome(null, List.of())) - .withMessageContaining("outcome"); + void exposesUpAndDependencies() { + HealthOutcome o = new HealthOutcome(true, List.of(new Dependency("jdbc", true))); + assertThat(o.up()).isTrue(); + assertThat(o.dependencies()).containsExactly(new Dependency("jdbc", true)); } @Test void coercesNullDependenciesToEmpty() { - HealthOutcome o = new HealthOutcome("Up", null); + HealthOutcome o = new HealthOutcome(true, null); assertThat(o.dependencies()).isEmpty(); } @Test void copiesDependencyListDefensively() { List mutable = new ArrayList<>(); - mutable.add(new Dependency("jdbc", "Up")); - HealthOutcome o = new HealthOutcome("Up", mutable); + mutable.add(new Dependency("jdbc", true)); + HealthOutcome o = new HealthOutcome(true, mutable); mutable.clear(); assertThat(o.dependencies()).hasSize(1); } - - @Test - void isUpReturnsTrueForUpCaseInsensitive() { - assertThat(new HealthOutcome("Up", List.of()).isUp()).isTrue(); - assertThat(new HealthOutcome("UP", List.of()).isUp()).isTrue(); - assertThat(new HealthOutcome("up", List.of()).isUp()).isTrue(); - } - - @Test - void isUpReturnsFalseForAnythingElse() { - assertThat(new HealthOutcome("Down", List.of()).isUp()).isFalse(); - assertThat(new HealthOutcome("", List.of()).isUp()).isFalse(); - assertThat(new HealthOutcome("Degraded", List.of()).isUp()).isFalse(); - } } diff --git a/src/test/java/com/retailsvc/http/internal/HealthRendererTest.java b/src/test/java/com/retailsvc/http/internal/HealthRendererTest.java deleted file mode 100644 index 0e798c7..0000000 --- a/src/test/java/com/retailsvc/http/internal/HealthRendererTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.retailsvc.http.internal; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.retailsvc.http.Dependency; -import com.retailsvc.http.HealthOutcome; -import java.util.List; -import org.junit.jupiter.api.Test; - -class HealthRendererTest { - - private static final ObjectMapper MAPPER = new ObjectMapper(); - - @Test - void rendersOutcomeAndEmptyDependencies() { - String json = HealthRenderer.toJson(new HealthOutcome("Up", List.of())); - assertThat(json).isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[]}"); - } - - @Test - void rendersOutcomeAndDependencies() throws Exception { - String json = - HealthRenderer.toJson( - new HealthOutcome( - "Down", List.of(new Dependency("jdbc", "Down"), new Dependency("cache", "Up")))); - - JsonNode root = MAPPER.readTree(json); - assertThat(root.get("outcome").asText()).isEqualTo("Down"); - assertThat(root.get("dependencies")).hasSize(2); - assertThat(root.get("dependencies").get(0).get("id").asText()).isEqualTo("jdbc"); - assertThat(root.get("dependencies").get(0).get("status").asText()).isEqualTo("Down"); - assertThat(root.get("dependencies").get(1).get("id").asText()).isEqualTo("cache"); - assertThat(root.get("dependencies").get(1).get("status").asText()).isEqualTo("Up"); - } - - @Test - void escapesQuotesAndBackslashes() throws Exception { - String json = - HealthRenderer.toJson(new HealthOutcome("Up", List.of(new Dependency("a\"b\\c", "Up")))); - JsonNode root = MAPPER.readTree(json); - assertThat(root.get("dependencies").get(0).get("id").asText()).isEqualTo("a\"b\\c"); - } - - @Test - void escapesControlCharacters() throws Exception { - String id = "tab\there\nnextend"; - String json = HealthRenderer.toJson(new HealthOutcome("Up", List.of(new Dependency(id, "Up")))); - JsonNode root = MAPPER.readTree(json); - assertThat(root.get("dependencies").get(0).get("id").asText()).isEqualTo(id); - } -} From db08608c5ec241c0bb31c6c571ad2c9077b57564 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 11:08:30 +0200 Subject: [PATCH 13/14] test: Address Sonar findings on HealthHandlerTest --- .../com/retailsvc/http/HealthHandlerTest.java | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/test/java/com/retailsvc/http/HealthHandlerTest.java b/src/test/java/com/retailsvc/http/HealthHandlerTest.java index 288e3ca..c9dfc0c 100644 --- a/src/test/java/com/retailsvc/http/HealthHandlerTest.java +++ b/src/test/java/com/retailsvc/http/HealthHandlerTest.java @@ -4,7 +4,6 @@ import static java.net.HttpURLConnection.HTTP_OK; import static java.net.HttpURLConnection.HTTP_UNAVAILABLE; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -33,10 +32,10 @@ void getReturns200AndJsonBodyWhenUp() throws IOException { Handlers.healthHandler(JSON, () -> outcome).handle(ex); - verify(ex).sendResponseHeaders(eq(HTTP_OK), eq((long) body.size())); + verify(ex).sendResponseHeaders(HTTP_OK, (long) body.size()); assertThat(headers.getFirst("Content-Type")).isEqualTo("application/json"); - assertThat(body.toString()) - .isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"jdbc\",\"status\":\"Up\"}]}"); + assertThat(body) + .hasToString("{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"jdbc\",\"status\":\"Up\"}]}"); } @Test @@ -49,8 +48,8 @@ void getReturns200WithEmptyDependencyArrayWhenNoDeps() throws IOException { Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of())).handle(ex); - verify(ex).sendResponseHeaders(eq(HTTP_OK), eq((long) body.size())); - assertThat(body.toString()).isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[]}"); + verify(ex).sendResponseHeaders(HTTP_OK, (long) body.size()); + assertThat(body).hasToString("{\"outcome\":\"Up\",\"dependencies\":[]}"); } @Test @@ -64,10 +63,10 @@ void getReturns503WhenDown() throws IOException { Handlers.healthHandler(JSON, () -> outcome).handle(ex); - verify(ex).sendResponseHeaders(eq(HTTP_UNAVAILABLE), eq((long) body.size())); + verify(ex).sendResponseHeaders(HTTP_UNAVAILABLE, (long) body.size()); assertThat(headers.getFirst("Content-Type")).isEqualTo("application/json"); - assertThat(body.toString()) - .isEqualTo( + assertThat(body) + .hasToString( "{\"outcome\":\"Down\",\"dependencies\":[{\"id\":\"jdbc\",\"status\":\"Down\"}]}"); } @@ -81,7 +80,7 @@ void headIsAccepted() throws IOException { Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of())).handle(ex); - verify(ex).sendResponseHeaders(eq(HTTP_OK), eq((long) body.size())); + verify(ex).sendResponseHeaders(HTTP_OK, (long) body.size()); } @Test @@ -110,8 +109,8 @@ void runtimeExceptionFromProbeMapsToDown503() throws IOException { }; Handlers.healthHandler(JSON, failing).handle(ex); - verify(ex).sendResponseHeaders(eq(HTTP_UNAVAILABLE), eq((long) body.size())); - assertThat(body.toString()).isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}"); + verify(ex).sendResponseHeaders(HTTP_UNAVAILABLE, (long) body.size()); + assertThat(body).hasToString("{\"outcome\":\"Down\",\"dependencies\":[]}"); } @Test @@ -124,8 +123,8 @@ void nullReturnFromProbeMapsToDown503() throws IOException { Handlers.healthHandler(JSON, () -> null).handle(ex); - verify(ex).sendResponseHeaders(eq(HTTP_UNAVAILABLE), eq((long) body.size())); - assertThat(body.toString()).isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}"); + verify(ex).sendResponseHeaders(HTTP_UNAVAILABLE, (long) body.size()); + assertThat(body).hasToString("{\"outcome\":\"Down\",\"dependencies\":[]}"); } private static HttpExchange newExchange(String method) { From 7709a904f463965196f08839c8ed4a8455b22da3 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 11:13:49 +0200 Subject: [PATCH 14/14] fix: Use record DTO to preserve health JSON field order --- .../java/com/retailsvc/http/Handlers.java | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index 582da93..10ed1e3 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -15,7 +15,6 @@ import com.sun.net.httpserver.HttpHandler; import java.io.IOException; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -88,10 +87,10 @@ public static HttpHandler aliveHandler() { * *

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 {@code Map} matching the shape above; any standard JSON library - * (Gson, Jackson, …) serialises it identically. + * the mapper a record-shaped DTO with the components in the order shown above; any standard JSON + * library (Gson, Jackson, …) serialises it identically. * - * @param jsonMapper used to encode the wire-shape {@code Map} to bytes + * @param jsonMapper used to encode the wire-shape DTO to bytes * @param probe supplier of the current {@link HealthOutcome} */ public static HttpHandler healthHandler(TypeMapper jsonMapper, Supplier probe) { @@ -116,19 +115,24 @@ public static HttpHandler healthHandler(TypeMapper jsonMapper, Supplier toWireShape(HealthOutcome outcome) { - return Map.of( - "outcome", label(outcome.up()), - "dependencies", - outcome.dependencies().stream() - .map(d -> Map.of("id", d.id(), "status", label(d.up()))) - .toList()); + 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}.