Skip to content

Commit 9cf7584

Browse files
committed
refactor: Use TypeMapper for health serialisation and boolean outcome
1 parent 0fee6d0 commit 9cf7584

10 files changed

Lines changed: 117 additions & 231 deletions

File tree

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
/**
66
* A single dependency entry within a {@link HealthOutcome}.
77
*
8+
* <p>The library translates {@code up} into the wire value {@code "Up"} or {@code "Down"} for the
9+
* {@code status} field.
10+
*
811
* @param id stable identifier of the dependency (e.g. {@code "jdbc"})
9-
* @param status free-form status; {@code "Up"} (case-insensitive) is treated as healthy by {@link
10-
* HealthOutcome#isUp()}; any other value is treated as unhealthy
12+
* @param up whether the dependency is healthy
1113
*/
12-
public record Dependency(String id, String status) {
14+
public record Dependency(String id, boolean up) {
1315
public Dependency {
1416
Objects.requireNonNull(id, "id");
15-
Objects.requireNonNull(status, "status");
1617
}
1718
}

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

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010
import static java.nio.charset.StandardCharsets.UTF_8;
1111

1212
import com.retailsvc.http.internal.ClasspathResourceHandler;
13-
import com.retailsvc.http.internal.HealthRenderer;
1413
import com.retailsvc.http.internal.MethodLimitedHandler;
1514
import com.retailsvc.http.internal.ProblemDetailRenderer;
1615
import com.sun.net.httpserver.HttpHandler;
1716
import java.io.IOException;
1817
import java.util.List;
18+
import java.util.Map;
1919
import java.util.Objects;
2020
import java.util.function.Supplier;
2121
import java.util.stream.Collectors;
@@ -75,14 +75,27 @@ public static HttpHandler aliveHandler() {
7575

7676
/**
7777
* Health endpoint handler. Accepts GET and HEAD; returns 200 with {@code application/json} body
78-
* when the supplied probe reports {@code "Up"} (case-insensitive), and 503 with the same body
79-
* shape otherwise. A probe that throws a {@link RuntimeException} or returns {@code null} is
80-
* mapped to a {@code "Down"} outcome with an empty dependency list (and 503); the failure is
81-
* never propagated to the default exception handler.
78+
* when the supplied probe reports {@code up == true}, and 503 with the same body shape otherwise.
79+
* A probe that throws a {@link RuntimeException} or returns {@code null} is mapped to a {@code
80+
* Down} outcome with an empty dependency list (and 503); the failure is never propagated to the
81+
* default exception handler.
8282
*
83+
* <p>The wire shape is
84+
*
85+
* <pre>{@code
86+
* {"outcome":"Up","dependencies":[{"id":"jdbc","status":"Up"}]}
87+
* }</pre>
88+
*
89+
* <p>Serialisation is delegated to the supplied {@code jsonMapper} — typically the same {@link
90+
* TypeMapper} the caller registered for {@code application/json} on the server. The handler hands
91+
* the mapper a {@code Map<String,Object>} matching the shape above; any standard JSON library
92+
* (Gson, Jackson, …) serialises it identically.
93+
*
94+
* @param jsonMapper used to encode the wire-shape {@code Map} to bytes
8395
* @param probe supplier of the current {@link HealthOutcome}
8496
*/
85-
public static HttpHandler healthHandler(Supplier<HealthOutcome> probe) {
97+
public static HttpHandler healthHandler(TypeMapper jsonMapper, Supplier<HealthOutcome> probe) {
98+
Objects.requireNonNull(jsonMapper, "jsonMapper");
8699
Objects.requireNonNull(probe, "probe");
87100
return new MethodLimitedHandler(
88101
exchange -> {
@@ -92,17 +105,30 @@ public static HttpHandler healthHandler(Supplier<HealthOutcome> probe) {
92105
outcome = Objects.requireNonNull(probe.get(), "Health probe returned null");
93106
} catch (RuntimeException e) {
94107
LOG.warn("Health probe failed", e);
95-
outcome = new HealthOutcome("Down", List.of());
108+
outcome = new HealthOutcome(false, List.of());
96109
}
97-
byte[] body = HealthRenderer.toJson(outcome).getBytes(UTF_8);
98-
int status = outcome.isUp() ? HTTP_OK : HTTP_UNAVAILABLE;
110+
byte[] body = jsonMapper.writeTo(toWireShape(outcome));
111+
int status = outcome.up() ? HTTP_OK : HTTP_UNAVAILABLE;
99112
exchange.getResponseHeaders().add("Content-Type", "application/json");
100113
exchange.sendResponseHeaders(status, body.length);
101114
exchange.getResponseBody().write(body);
102115
}
103116
});
104117
}
105118

119+
private static Map<String, Object> toWireShape(HealthOutcome outcome) {
120+
return Map.of(
121+
"outcome", label(outcome.up()),
122+
"dependencies",
123+
outcome.dependencies().stream()
124+
.map(d -> Map.<String, Object>of("id", d.id(), "status", label(d.up())))
125+
.toList());
126+
}
127+
128+
private static String label(boolean up) {
129+
return up ? "Up" : "Down";
130+
}
131+
106132
/**
107133
* Serves a classpath resource. Content-Type is inferred from the file extension. The resource is
108134
* loaded eagerly; a missing resource fails immediately with {@link IllegalArgumentException}.

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

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,19 @@
44
import java.util.Objects;
55

66
/**
7-
* Wire-shape carrier for the {@link Handlers#healthHandler health handler} response.
7+
* Carrier for the {@link Handlers#healthHandler health handler} response.
88
*
9-
* <p>The record owns the JSON shape on the wire {@code {"outcome": "...", "dependencies": [
10-
* {"id": "...", "status": "..."} ]}}. Construct it from whatever check-running mechanism the caller
11-
* prefers; this library has no opinion.
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.
1212
*
13-
* @param outcome overall outcome; {@code "Up"} (case-insensitive) means healthy
13+
* @param up overall health — {@code true} renders as {@code "Up"} with HTTP 200; {@code false}
14+
* renders as {@code "Down"} with HTTP 503
1415
* @param dependencies per-dependency statuses; {@code null} is normalised to an empty list
1516
*/
16-
public record HealthOutcome(String outcome, List<Dependency> dependencies) {
17+
public record HealthOutcome(boolean up, List<Dependency> dependencies) {
1718

1819
public HealthOutcome {
19-
Objects.requireNonNull(outcome, "outcome");
2020
dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of()));
2121
}
22-
23-
/** Returns {@code true} when {@link #outcome()} equals {@code "Up"} ignoring case. */
24-
public boolean isUp() {
25-
return "Up".equalsIgnoreCase(outcome);
26-
}
2722
}

src/main/java/com/retailsvc/http/internal/HealthRenderer.java

Lines changed: 0 additions & 43 deletions
This file was deleted.

src/main/java/com/retailsvc/http/internal/JsonStrings.java

Lines changed: 0 additions & 52 deletions
This file was deleted.

src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,41 +17,77 @@ public final class ProblemDetailRenderer {
1717
/** Initial capacity of the JSON buffer; sized for a typical problem-detail document. */
1818
private static final int INITIAL_BUFFER_CAPACITY = 128;
1919

20+
/** Codepoints below this value are control characters and must be unicode-escaped in JSON. */
21+
private static final int FIRST_PRINTABLE_ASCII = 0x20;
22+
2023
private ProblemDetailRenderer() {}
2124

2225
public static String render(int status, String title, String detail) {
2326
StringBuilder out = new StringBuilder(INITIAL_BUFFER_CAPACITY);
2427
out.append('{');
25-
JsonStrings.appendStringField(out, "type", PROBLEM_TYPE);
28+
appendStringField(out, "type", PROBLEM_TYPE);
2629
out.append(',');
27-
JsonStrings.appendStringField(out, "title", title);
30+
appendStringField(out, "title", title);
2831
out.append(',');
2932
appendIntField(out, "status", status);
3033
out.append(',');
31-
JsonStrings.appendStringField(out, "detail", detail);
34+
appendStringField(out, "detail", detail);
3235
out.append('}');
3336
return out.toString();
3437
}
3538

3639
public static String render(ValidationError error) {
3740
StringBuilder out = new StringBuilder(INITIAL_BUFFER_CAPACITY);
3841
out.append('{');
39-
JsonStrings.appendStringField(out, "type", PROBLEM_TYPE);
42+
appendStringField(out, "type", PROBLEM_TYPE);
4043
out.append(',');
41-
JsonStrings.appendStringField(out, "title", PROBLEM_TITLE);
44+
appendStringField(out, "title", PROBLEM_TITLE);
4245
out.append(',');
4346
appendIntField(out, "status", PROBLEM_STATUS);
4447
out.append(',');
45-
JsonStrings.appendStringField(out, "detail", error.message());
48+
appendStringField(out, "detail", error.message());
4649
out.append(',');
47-
JsonStrings.appendStringField(out, "pointer", error.pointer());
50+
appendStringField(out, "pointer", error.pointer());
4851
out.append(',');
49-
JsonStrings.appendStringField(out, "keyword", error.keyword());
52+
appendStringField(out, "keyword", error.keyword());
5053
out.append('}');
5154
return out.toString();
5255
}
5356

57+
private static void appendStringField(StringBuilder out, String name, String value) {
58+
out.append('"').append(name).append("\":\"");
59+
appendEscaped(out, value);
60+
out.append('"');
61+
}
62+
5463
private static void appendIntField(StringBuilder out, String name, int value) {
5564
out.append('"').append(name).append("\":").append(value);
5665
}
66+
67+
/**
68+
* Appends {@code value} to {@code out} with JSON-string escaping applied. Handles the six
69+
* mandatory escape sequences and emits {@code &#92;uXXXX} for control characters below {@link
70+
* #FIRST_PRINTABLE_ASCII}.
71+
*/
72+
private static void appendEscaped(StringBuilder out, String value) {
73+
for (int i = 0; i < value.length(); i++) {
74+
char c = value.charAt(i);
75+
switch (c) {
76+
case '\\' -> out.append("\\\\");
77+
case '"' -> out.append("\\\"");
78+
case '\n' -> out.append("\\n");
79+
case '\r' -> out.append("\\r");
80+
case '\t' -> out.append("\\t");
81+
default -> appendUnicodeOrLiteral(out, c);
82+
}
83+
}
84+
}
85+
86+
private static void appendUnicodeOrLiteral(StringBuilder out, char c) {
87+
if (c < FIRST_PRINTABLE_ASCII) {
88+
out.append(String.format("\\u%04x", (int) c));
89+
} else {
90+
out.append(c);
91+
}
92+
}
5793
}

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

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,16 @@
88
class DependencyTest {
99

1010
@Test
11-
void holdsIdAndStatus() {
12-
Dependency d = new Dependency("jdbc", "Up");
11+
void holdsIdAndUp() {
12+
Dependency d = new Dependency("jdbc", true);
1313
assertThat(d.id()).isEqualTo("jdbc");
14-
assertThat(d.status()).isEqualTo("Up");
14+
assertThat(d.up()).isTrue();
1515
}
1616

1717
@Test
1818
void rejectsNullId() {
1919
assertThatNullPointerException()
20-
.isThrownBy(() -> new Dependency(null, "Up"))
20+
.isThrownBy(() -> new Dependency(null, true))
2121
.withMessageContaining("id");
2222
}
23-
24-
@Test
25-
void rejectsNullStatus() {
26-
assertThatNullPointerException()
27-
.isThrownBy(() -> new Dependency("jdbc", null))
28-
.withMessageContaining("status");
29-
}
3023
}

0 commit comments

Comments
 (0)