Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 17 additions & 34 deletions src/main/java/com/retailsvc/http/Handlers.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;
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;
import com.retailsvc.http.internal.HealthRenderer;
import com.retailsvc.http.internal.ProblemDetail;
import java.util.List;
import java.util.Objects;
Expand Down Expand Up @@ -61,63 +63,44 @@ public static RequestHandler 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.
* when the supplied probe reports up (all dependencies up, or no dependencies), 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} response with an empty dependency list (and 503); the failure
* is never propagated to the default exception handler.
*
* <p>The wire shape is
*
* <pre>{@code
* {"outcome":"Up","dependencies":[{"id":"jdbc","status":"Up"}]}
* }</pre>
*
* <p>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.
* <p>The body is rendered by a built-in writer; no JSON library on the classpath is required.
*
* @param jsonMapper used to encode the wire-shape DTO to bytes
* @param probe supplier of the current {@link HealthOutcome}
*/
public static RequestHandler healthHandler(TypeMapper jsonMapper, Supplier<HealthOutcome> probe) {
Objects.requireNonNull(jsonMapper, "jsonMapper");
public static RequestHandler healthHandler(Supplier<HealthOutcome> probe) {
Objects.requireNonNull(probe, "probe");
return req -> {
if (req.method() != GET && req.method() != HEAD) {
return Response.status(HTTP_BAD_METHOD).withHeader("Allow", "GET, HEAD");
}
HealthOutcome outcome;
boolean up;
List<Dependency> dependencies;
try {
outcome = Objects.requireNonNull(probe.get(), "Health probe returned null");
HealthOutcome outcome = Objects.requireNonNull(probe.get(), "Health probe returned null");
up = outcome.up();
dependencies = outcome.dependencies();
} catch (RuntimeException e) {
LOG.warn("Health probe failed", e);
outcome = new HealthOutcome(false, List.of());
up = false;
dependencies = List.of();
}
byte[] body = jsonMapper.writeTo(toWireShape(outcome));
int status = outcome.up() ? HTTP_OK : HTTP_UNAVAILABLE;
byte[] body = HealthRenderer.renderJson(up, dependencies).getBytes(UTF_8);
int status = up ? HTTP_OK : HTTP_UNAVAILABLE;
return Response.bytes(status, body, "application/json");
};
}

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<DependencyBody> 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}.
Expand Down
15 changes: 9 additions & 6 deletions src/main/java/com/retailsvc/http/HealthOutcome.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,20 @@
/**
* Carrier for the {@link Handlers#healthHandler health handler} response.
*
* <p>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.
* <p>Overall health is derived from {@link #dependencies()}: an empty list reports as {@code "Up"};
* otherwise the outcome is {@code "Up"} only when every dependency is up. Callers describe their
* dependencies and the library aggregates.
*
* @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<Dependency> dependencies) {
public record HealthOutcome(List<Dependency> dependencies) {

public HealthOutcome {
dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of()));
}

/** {@code true} when every dependency is up (vacuously true for an empty list). */
public boolean up() {
return dependencies.stream().allMatch(Dependency::up);
}
}
59 changes: 59 additions & 0 deletions src/main/java/com/retailsvc/http/internal/HealthRenderer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.retailsvc.http.internal;

import com.retailsvc.http.Dependency;
import java.util.List;

/**
* Built-in JSON writer for the health endpoint wire shape. Keeps {@code Handlers} free of
* serialisation logic and removes the need for a JSON library on the classpath.
*/
public final class HealthRenderer {

/** Initial StringBuilder capacity sized for an outcome with one dependency (the common case). */
private static final int INITIAL_CAPACITY = 64;

private HealthRenderer() {}

public static String renderJson(boolean up, List<Dependency> dependencies) {
StringBuilder sb = new StringBuilder(INITIAL_CAPACITY);
sb.append("{\"outcome\":\"").append(label(up)).append("\",\"dependencies\":[");
for (int i = 0; i < dependencies.size(); i++) {
if (i > 0) {
sb.append(',');
}
Dependency d = dependencies.get(i);
sb.append("{\"id\":");
appendJsonString(sb, d.id());
sb.append(",\"status\":\"").append(label(d.up())).append("\"}");
}
return sb.append("]}").toString();
}

private static void appendJsonString(StringBuilder sb, String s) {
sb.append('"');
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
case '\b' -> sb.append("\\b");
case '\f' -> sb.append("\\f");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default -> {
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
}
sb.append('"');
}

private static String label(boolean up) {
return up ? "Up" : "Down";
}
}
39 changes: 23 additions & 16 deletions src/test/java/com/retailsvc/http/HealthHandlerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import static java.net.HttpURLConnection.HTTP_UNAVAILABLE;
import static org.assertj.core.api.Assertions.assertThat;

import com.retailsvc.http.internal.gson.GsonJsonMapper;
import com.retailsvc.http.spec.HttpMethod;
import java.nio.charset.StandardCharsets;
import java.util.List;
Expand All @@ -19,18 +18,17 @@

class HealthHandlerTest {

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

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

@Test
void getReturns200AndJsonBodyWhenUp() {
HealthOutcome outcome = new HealthOutcome(true, List.of(new Dependency("jdbc", true)));
void getReturns200AndJsonBodyWhenAllDependenciesUp() {
HealthOutcome outcome = new HealthOutcome(List.of(new Dependency("jdbc", true)));

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

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

@Test
void getReturns200WithEmptyDependencyArrayWhenNoDeps() {
Response resp =
Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of())).handle(request(GET));
Response resp = Handlers.healthHandler(() -> new HealthOutcome(List.of())).handle(request(GET));

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

@Test
void getReturns503WhenDown() {
HealthOutcome outcome = new HealthOutcome(false, List.of(new Dependency("jdbc", false)));
void getReturns503WhenAnyDependencyDown() {
HealthOutcome outcome = new HealthOutcome(List.of(new Dependency("jdbc", false)));

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

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

assertThat(resp.status()).isEqualTo(HTTP_OK);
}

@Test
void postReturns405WithAllowHeader() {
Response resp =
Handlers.healthHandler(JSON, () -> new HealthOutcome(true, List.of()))
.handle(request(POST));
Handlers.healthHandler(() -> new HealthOutcome(List.of())).handle(request(POST));

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

Response resp = Handlers.healthHandler(JSON, failing).handle(request(GET));
Response resp = Handlers.healthHandler(failing).handle(request(GET));

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

@Test
void nullReturnFromProbeMapsToDown503() {
Response resp = Handlers.healthHandler(JSON, () -> null).handle(request(GET));
Response resp = Handlers.healthHandler(() -> null).handle(request(GET));

assertThat(resp.status()).isEqualTo(HTTP_UNAVAILABLE);
assertThat(new String((byte[]) resp.body(), StandardCharsets.UTF_8))
.isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}");
}

@Test
void escapesSpecialCharsInDependencyId() {
HealthOutcome outcome = new HealthOutcome(List.of(new Dependency("a\"b\\c\nd", true)));

Response resp = Handlers.healthHandler(() -> outcome).handle(request(GET));

assertThat(new String((byte[]) resp.body(), StandardCharsets.UTF_8))
.isEqualTo(
"{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"a\\\"b\\\\c\\n"
+ "d\",\"status\":\"Up\"}]}");
}
}
28 changes: 23 additions & 5 deletions src/test/java/com/retailsvc/http/HealthOutcomeTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,42 @@
class HealthOutcomeTest {

@Test
void exposesUpAndDependencies() {
HealthOutcome o = new HealthOutcome(true, List.of(new Dependency("jdbc", true)));
assertThat(o.up()).isTrue();
void exposesDependencies() {
HealthOutcome o = new HealthOutcome(List.of(new Dependency("jdbc", true)));
assertThat(o.dependencies()).containsExactly(new Dependency("jdbc", true));
}

@Test
void coercesNullDependenciesToEmpty() {
HealthOutcome o = new HealthOutcome(true, null);
HealthOutcome o = new HealthOutcome(null);
assertThat(o.dependencies()).isEmpty();
}

@Test
void copiesDependencyListDefensively() {
List<Dependency> mutable = new ArrayList<>();
mutable.add(new Dependency("jdbc", true));
HealthOutcome o = new HealthOutcome(true, mutable);
HealthOutcome o = new HealthOutcome(mutable);
mutable.clear();
assertThat(o.dependencies()).hasSize(1);
}

@Test
void emptyDependenciesIsUp() {
assertThat(new HealthOutcome(List.of()).up()).isTrue();
}

@Test
void upWhenAllDependenciesUp() {
HealthOutcome o =
new HealthOutcome(List.of(new Dependency("jdbc", true), new Dependency("redis", true)));
assertThat(o.up()).isTrue();
}

@Test
void downWhenAnyDependencyDown() {
HealthOutcome o =
new HealthOutcome(List.of(new Dependency("jdbc", true), new Dependency("redis", false)));
assertThat(o.up()).isFalse();
}
}
67 changes: 67 additions & 0 deletions src/test/java/com/retailsvc/http/internal/HealthRendererTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.retailsvc.http.internal;

import static org.assertj.core.api.Assertions.assertThat;

import com.retailsvc.http.Dependency;
import java.util.List;
import org.junit.jupiter.api.Test;

class HealthRendererTest {

@Test
void rendersUpWithNoDependencies() {
assertThat(HealthRenderer.renderJson(true, List.of()))
.isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[]}");
}

@Test
void rendersDownWithNoDependencies() {
assertThat(HealthRenderer.renderJson(false, List.of()))
.isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}");
}

@Test
void rendersSingleDependency() {
assertThat(HealthRenderer.renderJson(true, List.of(new Dependency("jdbc", true))))
.isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"jdbc\",\"status\":\"Up\"}]}");
}

@Test
void rendersMultipleDependenciesInOrderWithCommaSeparators() {
List<Dependency> deps = List.of(new Dependency("jdbc", true), new Dependency("redis", false));
assertThat(HealthRenderer.renderJson(false, deps))
.isEqualTo(
"{\"outcome\":\"Down\",\"dependencies\":["
+ "{\"id\":\"jdbc\",\"status\":\"Up\"},"
+ "{\"id\":\"redis\",\"status\":\"Down\"}]}");
}

@Test
void escapesQuoteAndBackslashInId() {
assertThat(HealthRenderer.renderJson(true, List.of(new Dependency("a\"b\\c", true))))
.isEqualTo(
"{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"a\\\"b\\\\c\",\"status\":\"Up\"}]}");
}

@Test
void escapesNamedControlCharsInId() {
assertThat(HealthRenderer.renderJson(true, List.of(new Dependency("\b\f\n\r\t", true))))
.isEqualTo(
"{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"\\b\\f\\n"
+ "\\r"
+ "\\t\",\"status\":\"Up\"}]}");
}

@Test
void escapesUnnamedControlCharsAsHexUnicode() {
assertThat(HealthRenderer.renderJson(true, List.of(new Dependency("", true))))
.isEqualTo(
"{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"\\u0001\\u001f\",\"status\":\"Up\"}]}");
}

@Test
void passesThroughNonAsciiCharactersVerbatim() {
assertThat(HealthRenderer.renderJson(true, List.of(new Dependency("café-é", true))))
.isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"café-é\",\"status\":\"Up\"}]}");
}
}
Loading