Skip to content

Commit 087acc3

Browse files
committed
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 fd6c003 commit 087acc3

5 files changed

Lines changed: 127 additions & 60 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: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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+
private HealthRenderer() {}
13+
14+
public static String renderJson(boolean up, List<Dependency> dependencies) {
15+
StringBuilder sb = new StringBuilder(64);
16+
sb.append("{\"outcome\":\"").append(label(up)).append("\",\"dependencies\":[");
17+
for (int i = 0; i < dependencies.size(); i++) {
18+
if (i > 0) {
19+
sb.append(',');
20+
}
21+
Dependency d = dependencies.get(i);
22+
sb.append("{\"id\":");
23+
appendJsonString(sb, d.id());
24+
sb.append(",\"status\":\"").append(label(d.up())).append("\"}");
25+
}
26+
return sb.append("]}").toString();
27+
}
28+
29+
private static void appendJsonString(StringBuilder sb, String s) {
30+
sb.append('"');
31+
for (int i = 0; i < s.length(); i++) {
32+
char c = s.charAt(i);
33+
switch (c) {
34+
case '"' -> sb.append("\\\"");
35+
case '\\' -> sb.append("\\\\");
36+
case '\b' -> sb.append("\\b");
37+
case '\f' -> sb.append("\\f");
38+
case '\n' -> sb.append("\\n");
39+
case '\r' -> sb.append("\\r");
40+
case '\t' -> sb.append("\\t");
41+
default -> {
42+
if (c < 0x20) {
43+
sb.append(String.format("\\u%04x", (int) c));
44+
} else {
45+
sb.append(c);
46+
}
47+
}
48+
}
49+
}
50+
sb.append('"');
51+
}
52+
53+
private static String label(boolean up) {
54+
return up ? "Up" : "Down";
55+
}
56+
}

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

Lines changed: 22 additions & 15 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");
@@ -41,18 +39,18 @@ void getReturns200AndJsonBodyWhenUp() {
4139
@Test
4240
void getReturns200WithEmptyDependencyArrayWhenNoDeps() {
4341
Response resp =
44-
Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of())).handle(request(GET));
42+
Handlers.healthHandler(() -> new HealthOutcome(List.of())).handle(request(GET));
4543

4644
assertThat(resp.status()).isEqualTo(HTTP_OK);
4745
assertThat(new String((byte[]) resp.body(), StandardCharsets.UTF_8))
4846
.isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[]}");
4947
}
5048

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

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

5755
assertThat(resp.status()).isEqualTo(HTTP_UNAVAILABLE);
5856
assertThat(resp.contentType()).isEqualTo("application/json");
@@ -64,17 +62,15 @@ void getReturns503WhenDown() {
6462
@Test
6563
void headIsAccepted() {
6664
Response resp =
67-
Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of()))
68-
.handle(request(HEAD));
65+
Handlers.healthHandler(() -> new HealthOutcome(List.of())).handle(request(HEAD));
6966

7067
assertThat(resp.status()).isEqualTo(HTTP_OK);
7168
}
7269

7370
@Test
7471
void postReturns405WithAllowHeader() {
7572
Response resp =
76-
Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of()))
77-
.handle(request(POST));
73+
Handlers.healthHandler(() -> new HealthOutcome(List.of())).handle(request(POST));
7874

7975
assertThat(resp.status()).isEqualTo(HTTP_BAD_METHOD);
8076
assertThat(resp.headers()).containsEntry("Allow", "GET, HEAD");
@@ -87,7 +83,7 @@ void runtimeExceptionFromProbeMapsToDown503() {
8783
throw new IllegalStateException("boom");
8884
};
8985

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

9288
assertThat(resp.status()).isEqualTo(HTTP_UNAVAILABLE);
9389
assertThat(new String((byte[]) resp.body(), StandardCharsets.UTF_8))
@@ -96,10 +92,21 @@ void runtimeExceptionFromProbeMapsToDown503() {
9692

9793
@Test
9894
void nullReturnFromProbeMapsToDown503() {
99-
Response resp = Handlers.healthHandler(JSON, () -> null).handle(request(GET));
95+
Response resp = Handlers.healthHandler(() -> null).handle(request(GET));
10096

10197
assertThat(resp.status()).isEqualTo(HTTP_UNAVAILABLE);
10298
assertThat(new String((byte[]) resp.body(), StandardCharsets.UTF_8))
10399
.isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}");
104100
}
101+
102+
@Test
103+
void escapesSpecialCharsInDependencyId() {
104+
HealthOutcome outcome = new HealthOutcome(List.of(new Dependency("a\"b\\c\nd", true)));
105+
106+
Response resp = Handlers.healthHandler(() -> outcome).handle(request(GET));
107+
108+
assertThat(new String((byte[]) resp.body(), StandardCharsets.UTF_8))
109+
.isEqualTo(
110+
"{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"a\\\"b\\\\c\\nd\",\"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
}

0 commit comments

Comments
 (0)