Skip to content

Commit d5a98b6

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 f24ea84 commit d5a98b6

4 files changed

Lines changed: 113 additions & 53 deletions

File tree

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

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -74,65 +74,90 @@ public static HttpHandler aliveHandler() {
7474

7575
/**
7676
* Health endpoint handler. Accepts GET and HEAD; returns 200 with {@code application/json} body
77-
* when the supplied probe reports {@code up == true}, and 503 with the same body shape otherwise.
78-
* A probe that throws a {@link RuntimeException} or returns {@code null} is mapped to a {@code
79-
* Down} outcome with an empty dependency list (and 503); the failure is never propagated to the
80-
* default exception handler.
77+
* when the supplied probe reports up (all dependencies up, or no dependencies), and 503 with the
78+
* same body shape otherwise. A probe that throws a {@link RuntimeException} or returns {@code
79+
* null} is mapped to a {@code Down} response with an empty dependency list (and 503); the failure
80+
* is never propagated to the default exception handler.
8181
*
8282
* <p>The wire shape is
8383
*
8484
* <pre>{@code
8585
* {"outcome":"Up","dependencies":[{"id":"jdbc","status":"Up"}]}
8686
* }</pre>
8787
*
88-
* <p>Serialisation is delegated to the supplied {@code jsonMapper} — typically the same {@link
89-
* TypeMapper} the caller registered for {@code application/json} on the server. The handler hands
90-
* the mapper a record-shaped DTO with the components in the order shown above; any standard JSON
91-
* library (Gson, Jackson, …) serialises it identically.
88+
* <p>The body is rendered by a built-in writer; no JSON library on the classpath is required.
9289
*
93-
* @param jsonMapper used to encode the wire-shape DTO to bytes
9490
* @param probe supplier of the current {@link HealthOutcome}
9591
*/
96-
public static HttpHandler healthHandler(TypeMapper jsonMapper, Supplier<HealthOutcome> probe) {
97-
Objects.requireNonNull(jsonMapper, "jsonMapper");
92+
public static HttpHandler healthHandler(Supplier<HealthOutcome> probe) {
9893
Objects.requireNonNull(probe, "probe");
9994
return new MethodLimitedHandler(
10095
exchange -> {
10196
try (exchange) {
102-
HealthOutcome outcome;
97+
boolean up;
98+
List<Dependency> dependencies;
10399
try {
104-
outcome = Objects.requireNonNull(probe.get(), "Health probe returned null");
100+
HealthOutcome outcome =
101+
Objects.requireNonNull(probe.get(), "Health probe returned null");
102+
up = outcome.up();
103+
dependencies = outcome.dependencies();
105104
} catch (RuntimeException e) {
106105
LOG.warn("Health probe failed", e);
107-
outcome = new HealthOutcome(false, List.of());
106+
up = false;
107+
dependencies = List.of();
108108
}
109-
byte[] body = jsonMapper.writeTo(toWireShape(outcome));
110-
int status = outcome.up() ? HTTP_OK : HTTP_UNAVAILABLE;
109+
byte[] body = renderJson(up, dependencies).getBytes(UTF_8);
110+
int status = up ? HTTP_OK : HTTP_UNAVAILABLE;
111111
exchange.getResponseHeaders().add("Content-Type", "application/json");
112112
exchange.sendResponseHeaders(status, body.length);
113113
exchange.getResponseBody().write(body);
114114
}
115115
});
116116
}
117117

118-
private static HealthBody toWireShape(HealthOutcome outcome) {
119-
return new HealthBody(
120-
label(outcome.up()),
121-
outcome.dependencies().stream()
122-
.map(d -> new DependencyBody(d.id(), label(d.up())))
123-
.toList());
118+
private static String renderJson(boolean up, List<Dependency> dependencies) {
119+
StringBuilder sb = new StringBuilder(64);
120+
sb.append("{\"outcome\":\"").append(label(up)).append("\",\"dependencies\":[");
121+
for (int i = 0; i < dependencies.size(); i++) {
122+
if (i > 0) {
123+
sb.append(',');
124+
}
125+
Dependency d = dependencies.get(i);
126+
sb.append("{\"id\":");
127+
appendJsonString(sb, d.id());
128+
sb.append(",\"status\":\"").append(label(d.up())).append("\"}");
129+
}
130+
return sb.append("]}").toString();
131+
}
132+
133+
private static void appendJsonString(StringBuilder sb, String s) {
134+
sb.append('"');
135+
for (int i = 0; i < s.length(); i++) {
136+
char c = s.charAt(i);
137+
switch (c) {
138+
case '"' -> sb.append("\\\"");
139+
case '\\' -> sb.append("\\\\");
140+
case '\b' -> sb.append("\\b");
141+
case '\f' -> sb.append("\\f");
142+
case '\n' -> sb.append("\\n");
143+
case '\r' -> sb.append("\\r");
144+
case '\t' -> sb.append("\\t");
145+
default -> {
146+
if (c < 0x20) {
147+
sb.append(String.format("\\u%04x", (int) c));
148+
} else {
149+
sb.append(c);
150+
}
151+
}
152+
}
153+
}
154+
sb.append('"');
124155
}
125156

126157
private static String label(boolean up) {
127158
return up ? "Up" : "Down";
128159
}
129160

130-
/** Wire-shape DTO for the health endpoint. Component order defines JSON field order. */
131-
private record HealthBody(String outcome, List<DependencyBody> dependencies) {}
132-
133-
/** Wire-shape DTO for a single dependency entry. */
134-
private record DependencyBody(String id, String status) {}
135-
136161
/**
137162
* Serves a classpath resource. Content-Type is inferred from the file extension. The resource is
138163
* 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
}

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

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import static org.mockito.Mockito.verify;
99
import static org.mockito.Mockito.when;
1010

11-
import com.retailsvc.http.internal.gson.GsonJsonMapper;
1211
import com.sun.net.httpserver.Headers;
1312
import com.sun.net.httpserver.HttpExchange;
1413
import java.io.ByteArrayOutputStream;
@@ -19,18 +18,16 @@
1918

2019
class HealthHandlerTest {
2120

22-
private static final TypeMapper JSON = new GsonJsonMapper();
23-
2421
@Test
25-
void getReturns200AndJsonBodyWhenUp() throws IOException {
26-
HealthOutcome outcome = new HealthOutcome(true, List.of(new Dependency("jdbc", true)));
22+
void getReturns200AndJsonBodyWhenAllDependenciesUp() throws IOException {
23+
HealthOutcome outcome = new HealthOutcome(List.of(new Dependency("jdbc", true)));
2724
HttpExchange ex = newExchange("GET");
2825
Headers headers = new Headers();
2926
when(ex.getResponseHeaders()).thenReturn(headers);
3027
ByteArrayOutputStream body = new ByteArrayOutputStream();
3128
when(ex.getResponseBody()).thenReturn(body);
3229

33-
Handlers.healthHandler(JSON, () -> outcome).handle(ex);
30+
Handlers.healthHandler(() -> outcome).handle(ex);
3431

3532
verify(ex).sendResponseHeaders(HTTP_OK, (long) body.size());
3633
assertThat(headers.getFirst("Content-Type")).isEqualTo("application/json");
@@ -46,22 +43,22 @@ void getReturns200WithEmptyDependencyArrayWhenNoDeps() throws IOException {
4643
ByteArrayOutputStream body = new ByteArrayOutputStream();
4744
when(ex.getResponseBody()).thenReturn(body);
4845

49-
Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of())).handle(ex);
46+
Handlers.healthHandler(() -> new HealthOutcome(List.of())).handle(ex);
5047

5148
verify(ex).sendResponseHeaders(HTTP_OK, (long) body.size());
5249
assertThat(body).hasToString("{\"outcome\":\"Up\",\"dependencies\":[]}");
5350
}
5451

5552
@Test
56-
void getReturns503WhenDown() throws IOException {
57-
HealthOutcome outcome = new HealthOutcome(false, List.of(new Dependency("jdbc", false)));
53+
void getReturns503WhenAnyDependencyDown() throws IOException {
54+
HealthOutcome outcome = new HealthOutcome(List.of(new Dependency("jdbc", false)));
5855
HttpExchange ex = newExchange("GET");
5956
Headers headers = new Headers();
6057
when(ex.getResponseHeaders()).thenReturn(headers);
6158
ByteArrayOutputStream body = new ByteArrayOutputStream();
6259
when(ex.getResponseBody()).thenReturn(body);
6360

64-
Handlers.healthHandler(JSON, () -> outcome).handle(ex);
61+
Handlers.healthHandler(() -> outcome).handle(ex);
6562

6663
verify(ex).sendResponseHeaders(HTTP_UNAVAILABLE, (long) body.size());
6764
assertThat(headers.getFirst("Content-Type")).isEqualTo("application/json");
@@ -78,7 +75,7 @@ void headIsAccepted() throws IOException {
7875
ByteArrayOutputStream body = new ByteArrayOutputStream();
7976
when(ex.getResponseBody()).thenReturn(body);
8077

81-
Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of())).handle(ex);
78+
Handlers.healthHandler(() -> new HealthOutcome(List.of())).handle(ex);
8279

8380
verify(ex).sendResponseHeaders(HTTP_OK, (long) body.size());
8481
}
@@ -89,7 +86,7 @@ void postReturns405WithAllowHeader() throws IOException {
8986
Headers headers = new Headers();
9087
when(ex.getResponseHeaders()).thenReturn(headers);
9188

92-
Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of())).handle(ex);
89+
Handlers.healthHandler(() -> new HealthOutcome(List.of())).handle(ex);
9390

9491
verify(ex).sendResponseHeaders(HTTP_BAD_METHOD, -1);
9592
assertThat(headers.getFirst("Allow")).isEqualTo("GET, HEAD");
@@ -107,7 +104,7 @@ void runtimeExceptionFromProbeMapsToDown503() throws IOException {
107104
() -> {
108105
throw new IllegalStateException("boom");
109106
};
110-
Handlers.healthHandler(JSON, failing).handle(ex);
107+
Handlers.healthHandler(failing).handle(ex);
111108

112109
verify(ex).sendResponseHeaders(HTTP_UNAVAILABLE, (long) body.size());
113110
assertThat(body).hasToString("{\"outcome\":\"Down\",\"dependencies\":[]}");
@@ -121,12 +118,29 @@ void nullReturnFromProbeMapsToDown503() throws IOException {
121118
ByteArrayOutputStream body = new ByteArrayOutputStream();
122119
when(ex.getResponseBody()).thenReturn(body);
123120

124-
Handlers.healthHandler(JSON, () -> null).handle(ex);
121+
Handlers.healthHandler(() -> null).handle(ex);
125122

126123
verify(ex).sendResponseHeaders(HTTP_UNAVAILABLE, (long) body.size());
127124
assertThat(body).hasToString("{\"outcome\":\"Down\",\"dependencies\":[]}");
128125
}
129126

127+
@Test
128+
void escapesSpecialCharsInDependencyId() throws IOException {
129+
HealthOutcome outcome = new HealthOutcome(List.of(new Dependency("a\"b\\c\nd", true)));
130+
HttpExchange ex = newExchange("GET");
131+
Headers headers = new Headers();
132+
when(ex.getResponseHeaders()).thenReturn(headers);
133+
ByteArrayOutputStream body = new ByteArrayOutputStream();
134+
when(ex.getResponseBody()).thenReturn(body);
135+
136+
Handlers.healthHandler(() -> outcome).handle(ex);
137+
138+
assertThat(body)
139+
.hasToString(
140+
"{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"a\\\"b\\\\c\\n"
141+
+ "d\",\"status\":\"Up\"}]}");
142+
}
143+
130144
private static HttpExchange newExchange(String method) {
131145
HttpExchange ex = mock(HttpExchange.class);
132146
when(ex.getRequestMethod()).thenReturn(method);

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)