Skip to content

Commit 6588fd3

Browse files
authored
fix: Drop TypeMapper from healthHandler and derive up from dependencies (#76)
* fix: Drop TypeMapper from healthHandler and derive up from dependencies healthHandler no longer requires a TypeMapper — the fixed wire shape is rendered by a built-in JSON writer with proper string escaping, so the health endpoint works without a JSON library on the classpath. HealthOutcome's up flag is now derived: empty dependencies report as up, otherwise up is the conjunction of every dependency's status.
1 parent 0f2ee65 commit 6588fd3

6 files changed

Lines changed: 198 additions & 61 deletions

File tree

src/main/java/com/retailsvc/http/Handlers.java

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;
88
import static java.net.HttpURLConnection.HTTP_OK;
99
import static java.net.HttpURLConnection.HTTP_UNAVAILABLE;
10+
import static java.nio.charset.StandardCharsets.UTF_8;
1011

1112
import com.retailsvc.http.internal.ClasspathResourceHandler;
13+
import com.retailsvc.http.internal.HealthRenderer;
1214
import com.retailsvc.http.internal.ProblemDetail;
1315
import java.util.List;
1416
import java.util.Objects;
@@ -61,63 +63,44 @@ public static RequestHandler aliveHandler() {
6163

6264
/**
6365
* Health endpoint handler. Accepts GET and HEAD; returns 200 with {@code application/json} body
64-
* when the supplied probe reports {@code up == true}, and 503 with the same body shape otherwise.
65-
* A probe that throws a {@link RuntimeException} or returns {@code null} is mapped to a {@code
66-
* Down} outcome with an empty dependency list (and 503); the failure is never propagated to the
67-
* default exception handler.
66+
* when the supplied probe reports up (all dependencies up, or no dependencies), and 503 with the
67+
* same body shape otherwise. A probe that throws a {@link RuntimeException} or returns {@code
68+
* null} is mapped to a {@code Down} response with an empty dependency list (and 503); the failure
69+
* is never propagated to the default exception handler.
6870
*
6971
* <p>The wire shape is
7072
*
7173
* <pre>{@code
7274
* {"outcome":"Up","dependencies":[{"id":"jdbc","status":"Up"}]}
7375
* }</pre>
7476
*
75-
* <p>Serialisation is delegated to the supplied {@code jsonMapper} — typically the same {@link
76-
* TypeMapper} the caller registered for {@code application/json} on the server. The handler hands
77-
* the mapper a record-shaped DTO with the components in the order shown above; any standard JSON
78-
* library (Gson, Jackson, …) serialises it identically.
77+
* <p>The body is rendered by a built-in writer; no JSON library on the classpath is required.
7978
*
80-
* @param jsonMapper used to encode the wire-shape DTO to bytes
8179
* @param probe supplier of the current {@link HealthOutcome}
8280
*/
83-
public static RequestHandler healthHandler(TypeMapper jsonMapper, Supplier<HealthOutcome> probe) {
84-
Objects.requireNonNull(jsonMapper, "jsonMapper");
81+
public static RequestHandler healthHandler(Supplier<HealthOutcome> probe) {
8582
Objects.requireNonNull(probe, "probe");
8683
return req -> {
8784
if (req.method() != GET && req.method() != HEAD) {
8885
return Response.status(HTTP_BAD_METHOD).withHeader("Allow", "GET, HEAD");
8986
}
90-
HealthOutcome outcome;
87+
boolean up;
88+
List<Dependency> dependencies;
9189
try {
92-
outcome = Objects.requireNonNull(probe.get(), "Health probe returned null");
90+
HealthOutcome outcome = Objects.requireNonNull(probe.get(), "Health probe returned null");
91+
up = outcome.up();
92+
dependencies = outcome.dependencies();
9393
} catch (RuntimeException e) {
9494
LOG.warn("Health probe failed", e);
95-
outcome = new HealthOutcome(false, List.of());
95+
up = false;
96+
dependencies = List.of();
9697
}
97-
byte[] body = jsonMapper.writeTo(toWireShape(outcome));
98-
int status = outcome.up() ? HTTP_OK : HTTP_UNAVAILABLE;
98+
byte[] body = HealthRenderer.renderJson(up, dependencies).getBytes(UTF_8);
99+
int status = up ? HTTP_OK : HTTP_UNAVAILABLE;
99100
return Response.bytes(status, body, "application/json");
100101
};
101102
}
102103

103-
private static HealthBody toWireShape(HealthOutcome outcome) {
104-
return new HealthBody(
105-
label(outcome.up()),
106-
outcome.dependencies().stream()
107-
.map(d -> new DependencyBody(d.id(), label(d.up())))
108-
.toList());
109-
}
110-
111-
private static String label(boolean up) {
112-
return up ? "Up" : "Down";
113-
}
114-
115-
/** Wire-shape DTO for the health endpoint. Component order defines JSON field order. */
116-
private record HealthBody(String outcome, List<DependencyBody> dependencies) {}
117-
118-
/** Wire-shape DTO for a single dependency entry. */
119-
private record DependencyBody(String id, String status) {}
120-
121104
/**
122105
* Serves a classpath resource. Content-Type is inferred from the file extension. The resource is
123106
* loaded eagerly; a missing resource fails immediately with {@link IllegalArgumentException}.

src/main/java/com/retailsvc/http/HealthOutcome.java

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,20 @@
66
/**
77
* Carrier for the {@link Handlers#healthHandler health handler} response.
88
*
9-
* <p>The library translates {@code up} into the wire value {@code "Up"} or {@code "Down"} on the
10-
* way out; callers work in booleans. Construct the outcome from whatever check-running mechanism
11-
* the caller prefers — this library has no opinion.
9+
* <p>Overall health is derived from {@link #dependencies()}: an empty list reports as {@code "Up"};
10+
* otherwise the outcome is {@code "Up"} only when every dependency is up. Callers describe their
11+
* dependencies and the library aggregates.
1212
*
13-
* @param up overall health — {@code true} renders as {@code "Up"} with HTTP 200; {@code false}
14-
* renders as {@code "Down"} with HTTP 503
1513
* @param dependencies per-dependency statuses; {@code null} is normalised to an empty list
1614
*/
17-
public record HealthOutcome(boolean up, List<Dependency> dependencies) {
15+
public record HealthOutcome(List<Dependency> dependencies) {
1816

1917
public HealthOutcome {
2018
dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of()));
2119
}
20+
21+
/** {@code true} when every dependency is up (vacuously true for an empty list). */
22+
public boolean up() {
23+
return dependencies.stream().allMatch(Dependency::up);
24+
}
2225
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.retailsvc.http.internal;
2+
3+
import com.retailsvc.http.Dependency;
4+
import java.util.List;
5+
6+
/**
7+
* Built-in JSON writer for the health endpoint wire shape. Keeps {@code Handlers} free of
8+
* serialisation logic and removes the need for a JSON library on the classpath.
9+
*/
10+
public final class HealthRenderer {
11+
12+
/** Initial StringBuilder capacity sized for an outcome with one dependency (the common case). */
13+
private static final int INITIAL_CAPACITY = 64;
14+
15+
private HealthRenderer() {}
16+
17+
public static String renderJson(boolean up, List<Dependency> dependencies) {
18+
StringBuilder sb = new StringBuilder(INITIAL_CAPACITY);
19+
sb.append("{\"outcome\":\"").append(label(up)).append("\",\"dependencies\":[");
20+
for (int i = 0; i < dependencies.size(); i++) {
21+
if (i > 0) {
22+
sb.append(',');
23+
}
24+
Dependency d = dependencies.get(i);
25+
sb.append("{\"id\":");
26+
appendJsonString(sb, d.id());
27+
sb.append(",\"status\":\"").append(label(d.up())).append("\"}");
28+
}
29+
return sb.append("]}").toString();
30+
}
31+
32+
private static void appendJsonString(StringBuilder sb, String s) {
33+
sb.append('"');
34+
for (int i = 0; i < s.length(); i++) {
35+
char c = s.charAt(i);
36+
switch (c) {
37+
case '"' -> sb.append("\\\"");
38+
case '\\' -> sb.append("\\\\");
39+
case '\b' -> sb.append("\\b");
40+
case '\f' -> sb.append("\\f");
41+
case '\n' -> sb.append("\\n");
42+
case '\r' -> sb.append("\\r");
43+
case '\t' -> sb.append("\\t");
44+
default -> {
45+
if (c < 0x20) {
46+
sb.append(String.format("\\u%04x", (int) c));
47+
} else {
48+
sb.append(c);
49+
}
50+
}
51+
}
52+
}
53+
sb.append('"');
54+
}
55+
56+
private static String label(boolean up) {
57+
return up ? "Up" : "Down";
58+
}
59+
}

src/test/java/com/retailsvc/http/HealthHandlerTest.java

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import static java.net.HttpURLConnection.HTTP_UNAVAILABLE;
99
import static org.assertj.core.api.Assertions.assertThat;
1010

11-
import com.retailsvc.http.internal.gson.GsonJsonMapper;
1211
import com.retailsvc.http.spec.HttpMethod;
1312
import java.nio.charset.StandardCharsets;
1413
import java.util.List;
@@ -19,18 +18,17 @@
1918

2019
class HealthHandlerTest {
2120

22-
private static final TypeMapper JSON = new GsonJsonMapper();
2321
private static final UnaryOperator<String> NO_HEADERS = name -> null;
2422

2523
private static Request request(HttpMethod method) {
2624
return new Request(new byte[0], null, null, null, Map.of(), null, NO_HEADERS, Map.of(), method);
2725
}
2826

2927
@Test
30-
void getReturns200AndJsonBodyWhenUp() {
31-
HealthOutcome outcome = new HealthOutcome(true, List.of(new Dependency("jdbc", true)));
28+
void getReturns200AndJsonBodyWhenAllDependenciesUp() {
29+
HealthOutcome outcome = new HealthOutcome(List.of(new Dependency("jdbc", true)));
3230

33-
Response resp = Handlers.healthHandler(JSON, () -> outcome).handle(request(GET));
31+
Response resp = Handlers.healthHandler(() -> outcome).handle(request(GET));
3432

3533
assertThat(resp.status()).isEqualTo(HTTP_OK);
3634
assertThat(resp.contentType()).isEqualTo("application/json");
@@ -40,19 +38,18 @@ void getReturns200AndJsonBodyWhenUp() {
4038

4139
@Test
4240
void getReturns200WithEmptyDependencyArrayWhenNoDeps() {
43-
Response resp =
44-
Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of())).handle(request(GET));
41+
Response resp = Handlers.healthHandler(() -> new HealthOutcome(List.of())).handle(request(GET));
4542

4643
assertThat(resp.status()).isEqualTo(HTTP_OK);
4744
assertThat(new String((byte[]) resp.body(), StandardCharsets.UTF_8))
4845
.isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[]}");
4946
}
5047

5148
@Test
52-
void getReturns503WhenDown() {
53-
HealthOutcome outcome = new HealthOutcome(false, List.of(new Dependency("jdbc", false)));
49+
void getReturns503WhenAnyDependencyDown() {
50+
HealthOutcome outcome = new HealthOutcome(List.of(new Dependency("jdbc", false)));
5451

55-
Response resp = Handlers.healthHandler(JSON, () -> outcome).handle(request(GET));
52+
Response resp = Handlers.healthHandler(() -> outcome).handle(request(GET));
5653

5754
assertThat(resp.status()).isEqualTo(HTTP_UNAVAILABLE);
5855
assertThat(resp.contentType()).isEqualTo("application/json");
@@ -64,17 +61,15 @@ void getReturns503WhenDown() {
6461
@Test
6562
void headIsAccepted() {
6663
Response resp =
67-
Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of()))
68-
.handle(request(HEAD));
64+
Handlers.healthHandler(() -> new HealthOutcome(List.of())).handle(request(HEAD));
6965

7066
assertThat(resp.status()).isEqualTo(HTTP_OK);
7167
}
7268

7369
@Test
7470
void postReturns405WithAllowHeader() {
7571
Response resp =
76-
Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of()))
77-
.handle(request(POST));
72+
Handlers.healthHandler(() -> new HealthOutcome(List.of())).handle(request(POST));
7873

7974
assertThat(resp.status()).isEqualTo(HTTP_BAD_METHOD);
8075
assertThat(resp.headers()).containsEntry("Allow", "GET, HEAD");
@@ -87,7 +82,7 @@ void runtimeExceptionFromProbeMapsToDown503() {
8782
throw new IllegalStateException("boom");
8883
};
8984

90-
Response resp = Handlers.healthHandler(JSON, failing).handle(request(GET));
85+
Response resp = Handlers.healthHandler(failing).handle(request(GET));
9186

9287
assertThat(resp.status()).isEqualTo(HTTP_UNAVAILABLE);
9388
assertThat(new String((byte[]) resp.body(), StandardCharsets.UTF_8))
@@ -96,10 +91,22 @@ void runtimeExceptionFromProbeMapsToDown503() {
9691

9792
@Test
9893
void nullReturnFromProbeMapsToDown503() {
99-
Response resp = Handlers.healthHandler(JSON, () -> null).handle(request(GET));
94+
Response resp = Handlers.healthHandler(() -> null).handle(request(GET));
10095

10196
assertThat(resp.status()).isEqualTo(HTTP_UNAVAILABLE);
10297
assertThat(new String((byte[]) resp.body(), StandardCharsets.UTF_8))
10398
.isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}");
10499
}
100+
101+
@Test
102+
void escapesSpecialCharsInDependencyId() {
103+
HealthOutcome outcome = new HealthOutcome(List.of(new Dependency("a\"b\\c\nd", true)));
104+
105+
Response resp = Handlers.healthHandler(() -> outcome).handle(request(GET));
106+
107+
assertThat(new String((byte[]) resp.body(), StandardCharsets.UTF_8))
108+
.isEqualTo(
109+
"{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"a\\\"b\\\\c\\n"
110+
+ "d\",\"status\":\"Up\"}]}");
111+
}
105112
}

src/test/java/com/retailsvc/http/HealthOutcomeTest.java

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,42 @@
99
class HealthOutcomeTest {
1010

1111
@Test
12-
void exposesUpAndDependencies() {
13-
HealthOutcome o = new HealthOutcome(true, List.of(new Dependency("jdbc", true)));
14-
assertThat(o.up()).isTrue();
12+
void exposesDependencies() {
13+
HealthOutcome o = new HealthOutcome(List.of(new Dependency("jdbc", true)));
1514
assertThat(o.dependencies()).containsExactly(new Dependency("jdbc", true));
1615
}
1716

1817
@Test
1918
void coercesNullDependenciesToEmpty() {
20-
HealthOutcome o = new HealthOutcome(true, null);
19+
HealthOutcome o = new HealthOutcome(null);
2120
assertThat(o.dependencies()).isEmpty();
2221
}
2322

2423
@Test
2524
void copiesDependencyListDefensively() {
2625
List<Dependency> mutable = new ArrayList<>();
2726
mutable.add(new Dependency("jdbc", true));
28-
HealthOutcome o = new HealthOutcome(true, mutable);
27+
HealthOutcome o = new HealthOutcome(mutable);
2928
mutable.clear();
3029
assertThat(o.dependencies()).hasSize(1);
3130
}
31+
32+
@Test
33+
void emptyDependenciesIsUp() {
34+
assertThat(new HealthOutcome(List.of()).up()).isTrue();
35+
}
36+
37+
@Test
38+
void upWhenAllDependenciesUp() {
39+
HealthOutcome o =
40+
new HealthOutcome(List.of(new Dependency("jdbc", true), new Dependency("redis", true)));
41+
assertThat(o.up()).isTrue();
42+
}
43+
44+
@Test
45+
void downWhenAnyDependencyDown() {
46+
HealthOutcome o =
47+
new HealthOutcome(List.of(new Dependency("jdbc", true), new Dependency("redis", false)));
48+
assertThat(o.up()).isFalse();
49+
}
3250
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.retailsvc.http.internal;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.retailsvc.http.Dependency;
6+
import java.util.List;
7+
import org.junit.jupiter.api.Test;
8+
9+
class HealthRendererTest {
10+
11+
@Test
12+
void rendersUpWithNoDependencies() {
13+
assertThat(HealthRenderer.renderJson(true, List.of()))
14+
.isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[]}");
15+
}
16+
17+
@Test
18+
void rendersDownWithNoDependencies() {
19+
assertThat(HealthRenderer.renderJson(false, List.of()))
20+
.isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}");
21+
}
22+
23+
@Test
24+
void rendersSingleDependency() {
25+
assertThat(HealthRenderer.renderJson(true, List.of(new Dependency("jdbc", true))))
26+
.isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"jdbc\",\"status\":\"Up\"}]}");
27+
}
28+
29+
@Test
30+
void rendersMultipleDependenciesInOrderWithCommaSeparators() {
31+
List<Dependency> deps = List.of(new Dependency("jdbc", true), new Dependency("redis", false));
32+
assertThat(HealthRenderer.renderJson(false, deps))
33+
.isEqualTo(
34+
"{\"outcome\":\"Down\",\"dependencies\":["
35+
+ "{\"id\":\"jdbc\",\"status\":\"Up\"},"
36+
+ "{\"id\":\"redis\",\"status\":\"Down\"}]}");
37+
}
38+
39+
@Test
40+
void escapesQuoteAndBackslashInId() {
41+
assertThat(HealthRenderer.renderJson(true, List.of(new Dependency("a\"b\\c", true))))
42+
.isEqualTo(
43+
"{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"a\\\"b\\\\c\",\"status\":\"Up\"}]}");
44+
}
45+
46+
@Test
47+
void escapesNamedControlCharsInId() {
48+
assertThat(HealthRenderer.renderJson(true, List.of(new Dependency("\b\f\n\r\t", true))))
49+
.isEqualTo(
50+
"{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"\\b\\f\\n"
51+
+ "\\r"
52+
+ "\\t\",\"status\":\"Up\"}]}");
53+
}
54+
55+
@Test
56+
void escapesUnnamedControlCharsAsHexUnicode() {
57+
assertThat(HealthRenderer.renderJson(true, List.of(new Dependency("", true))))
58+
.isEqualTo(
59+
"{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"\\u0001\\u001f\",\"status\":\"Up\"}]}");
60+
}
61+
62+
@Test
63+
void passesThroughNonAsciiCharactersVerbatim() {
64+
assertThat(HealthRenderer.renderJson(true, List.of(new Dependency("café-é", true))))
65+
.isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"café-é\",\"status\":\"Up\"}]}");
66+
}
67+
}

0 commit comments

Comments
 (0)