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..6d8e048 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-health-handler.md @@ -0,0 +1,687 @@ +# 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 +SKIP=commitlint git commit -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 +SKIP=commitlint git commit -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 +SKIP=commitlint git commit -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\":[]}"); + } + + @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; + } +} +``` + +- [ ] **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 { + 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); + } + }); + } +``` + +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 (7 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 +SKIP=commitlint git commit -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` → `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. + +**Placeholder scan:** No TBDs, no "implement later", every code step shows the actual code. 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..83fa558 --- /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 expected wire format is: + +```json +{ + "outcome": "Up", + "dependencies": [ + { "id": "jdbc", "status": "Up" } + ] +} +``` + +We want a ready-to-use `HttpHandler` in this repo that produces that exact +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 + +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 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 + +- 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. +- 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 `"Up"`, +`"UP"`, or `"up"` all map to a healthy 200 response. + +### 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 +server = OpenApiServer.builder() + .spec(spec) + .jsonMapper(mapper) + .handlers(operationHandlers) + .addHandler("/health", Handlers.healthHandler(() -> { + // 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 `Supplier` is the only place that knows how to compute health, which is +exactly where any third-party integration belongs. + +## Error handling + +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 (this is a transport-level failure, not a probe failure). + +## 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` → 503, body `{"outcome":"Down","dependencies":[]}` + (same behaviour as a throwing probe). + +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. 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..54458f3 --- /dev/null +++ b/src/main/java/com/retailsvc/http/Dependency.java @@ -0,0 +1,18 @@ +package com.retailsvc.http; + +import java.util.Objects; + +/** + * 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 up whether the dependency is healthy + */ +public record Dependency(String id, boolean up) { + public Dependency { + Objects.requireNonNull(id, "id"); + } +} diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index 45ad17b..10ed1e3 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -5,6 +5,8 @@ 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; @@ -12,6 +14,9 @@ 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 +72,67 @@ 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 == 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 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 DTO to bytes + * @param probe supplier of the current {@link HealthOutcome} + */ + public static HttpHandler healthHandler(TypeMapper jsonMapper, Supplier probe) { + Objects.requireNonNull(jsonMapper, "jsonMapper"); + Objects.requireNonNull(probe, "probe"); + return new MethodLimitedHandler( + exchange -> { + try (exchange) { + HealthOutcome outcome; + try { + outcome = Objects.requireNonNull(probe.get(), "Health probe returned null"); + } catch (RuntimeException e) { + LOG.warn("Health probe failed", e); + outcome = new HealthOutcome(false, List.of()); + } + 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); + } + }); + } + + private static HealthBody toWireShape(HealthOutcome outcome) { + return new HealthBody( + label(outcome.up()), + outcome.dependencies().stream() + .map(d -> new DependencyBody(d.id(), label(d.up()))) + .toList()); + } + + private static String label(boolean up) { + return up ? "Up" : "Down"; + } + + /** Wire-shape DTO for the health endpoint. Component order defines JSON field order. */ + private record HealthBody(String outcome, List dependencies) {} + + /** Wire-shape DTO for a single dependency entry. */ + private record DependencyBody(String id, String status) {} + /** * Serves a classpath resource. Content-Type is inferred from the file extension. The resource is * loaded eagerly; a missing resource fails immediately with {@link IllegalArgumentException}. diff --git a/src/main/java/com/retailsvc/http/HealthOutcome.java b/src/main/java/com/retailsvc/http/HealthOutcome.java new file mode 100644 index 0000000..8950e4f --- /dev/null +++ b/src/main/java/com/retailsvc/http/HealthOutcome.java @@ -0,0 +1,22 @@ +package com.retailsvc.http; + +import java.util.List; +import java.util.Objects; + +/** + * Carrier for the {@link Handlers#healthHandler health handler} response. + * + *

The library translates {@code up} into the wire value {@code "Up"} or {@code "Down"} on the + * way out; callers work in booleans. Construct the outcome from whatever check-running mechanism + * the caller prefers — this library has no opinion. + * + * @param up overall health — {@code true} renders as {@code "Up"} with HTTP 200; {@code false} + * renders as {@code "Down"} with HTTP 503 + * @param dependencies per-dependency statuses; {@code null} is normalised to an empty list + */ +public record HealthOutcome(boolean up, List dependencies) { + + public HealthOutcome { + dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of())); + } +} 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..b01ac5a --- /dev/null +++ b/src/test/java/com/retailsvc/http/DependencyTest.java @@ -0,0 +1,23 @@ +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 holdsIdAndUp() { + Dependency d = new Dependency("jdbc", true); + assertThat(d.id()).isEqualTo("jdbc"); + assertThat(d.up()).isTrue(); + } + + @Test + void rejectsNullId() { + assertThatNullPointerException() + .isThrownBy(() -> new Dependency(null, true)) + .withMessageContaining("id"); + } +} 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..c9dfc0c --- /dev/null +++ b/src/test/java/com/retailsvc/http/HealthHandlerTest.java @@ -0,0 +1,136 @@ +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.Mockito.mock; +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; +import java.io.IOException; +import java.util.List; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +class HealthHandlerTest { + + private static final TypeMapper JSON = new GsonJsonMapper(); + + @Test + void getReturns200AndJsonBodyWhenUp() throws IOException { + 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(JSON, () -> outcome).handle(ex); + + verify(ex).sendResponseHeaders(HTTP_OK, (long) body.size()); + assertThat(headers.getFirst("Content-Type")).isEqualTo("application/json"); + assertThat(body) + .hasToString("{\"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(JSON, () -> new HealthOutcome(true, List.of())).handle(ex); + + verify(ex).sendResponseHeaders(HTTP_OK, (long) body.size()); + assertThat(body).hasToString("{\"outcome\":\"Up\",\"dependencies\":[]}"); + } + + @Test + void getReturns503WhenDown() throws IOException { + 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(JSON, () -> outcome).handle(ex); + + verify(ex).sendResponseHeaders(HTTP_UNAVAILABLE, (long) body.size()); + assertThat(headers.getFirst("Content-Type")).isEqualTo("application/json"); + assertThat(body) + .hasToString( + "{\"outcome\":\"Down\",\"dependencies\":[{\"id\":\"jdbc\",\"status\":\"Down\"}]}"); + } + + @Test + void headIsAccepted() throws IOException { + HttpExchange ex = newExchange("HEAD"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + when(ex.getResponseBody()).thenReturn(body); + + Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of())).handle(ex); + + verify(ex).sendResponseHeaders(HTTP_OK, (long) body.size()); + } + + @Test + void postReturns405WithAllowHeader() throws IOException { + HttpExchange ex = newExchange("POST"); + Headers headers = new Headers(); + when(ex.getResponseHeaders()).thenReturn(headers); + + Handlers.healthHandler(JSON, () -> new HealthOutcome(true, 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(JSON, failing).handle(ex); + + verify(ex).sendResponseHeaders(HTTP_UNAVAILABLE, (long) body.size()); + assertThat(body).hasToString("{\"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(JSON, () -> null).handle(ex); + + verify(ex).sendResponseHeaders(HTTP_UNAVAILABLE, (long) body.size()); + assertThat(body).hasToString("{\"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; + } +} 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..0ef6a86 --- /dev/null +++ b/src/test/java/com/retailsvc/http/HealthOutcomeTest.java @@ -0,0 +1,32 @@ +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class HealthOutcomeTest { + + @Test + 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(true, null); + assertThat(o.dependencies()).isEmpty(); + } + + @Test + void copiesDependencyListDefensively() { + List mutable = new ArrayList<>(); + mutable.add(new Dependency("jdbc", true)); + HealthOutcome o = new HealthOutcome(true, mutable); + mutable.clear(); + assertThat(o.dependencies()).hasSize(1); + } +}