Skip to content

Commit cbc58bd

Browse files
committed
feat: Add Handlers.healthHandler
1 parent 3e09075 commit cbc58bd

2 files changed

Lines changed: 173 additions & 0 deletions

File tree

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,19 @@
55
import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;
66
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
77
import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
8+
import static java.net.HttpURLConnection.HTTP_OK;
9+
import static java.net.HttpURLConnection.HTTP_UNAVAILABLE;
810
import static java.nio.charset.StandardCharsets.UTF_8;
911

1012
import com.retailsvc.http.internal.ClasspathResourceHandler;
13+
import com.retailsvc.http.internal.HealthRenderer;
1114
import com.retailsvc.http.internal.MethodLimitedHandler;
1215
import com.retailsvc.http.internal.ProblemDetailRenderer;
1316
import com.sun.net.httpserver.HttpHandler;
1417
import java.io.IOException;
18+
import java.util.List;
19+
import java.util.Objects;
20+
import java.util.function.Supplier;
1521
import java.util.stream.Collectors;
1622
import org.slf4j.Logger;
1723
import org.slf4j.LoggerFactory;
@@ -67,6 +73,40 @@ public static HttpHandler aliveHandler() {
6773
});
6874
}
6975

76+
/**
77+
* 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.
82+
*
83+
* @param probe supplier of the current {@link HealthOutcome}
84+
*/
85+
public static HttpHandler healthHandler(Supplier<HealthOutcome> probe) {
86+
Objects.requireNonNull(probe, "probe");
87+
return new MethodLimitedHandler(
88+
exchange -> {
89+
try (exchange) {
90+
HealthOutcome outcome;
91+
try {
92+
HealthOutcome result = probe.get();
93+
if (result == null) {
94+
throw new IllegalStateException("Health probe returned null");
95+
}
96+
outcome = result;
97+
} catch (RuntimeException e) {
98+
LOG.warn("Health probe failed", e);
99+
outcome = new HealthOutcome("Down", List.of());
100+
}
101+
byte[] body = HealthRenderer.toJson(outcome).getBytes(UTF_8);
102+
int status = outcome.isUp() ? HTTP_OK : HTTP_UNAVAILABLE;
103+
exchange.getResponseHeaders().add("Content-Type", "application/json");
104+
exchange.sendResponseHeaders(status, body.length);
105+
exchange.getResponseBody().write(body);
106+
}
107+
});
108+
}
109+
70110
/**
71111
* Serves a classpath resource. Content-Type is inferred from the file extension. The resource is
72112
* loaded eagerly; a missing resource fails immediately with {@link IllegalArgumentException}.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package com.retailsvc.http;
2+
3+
import static java.net.HttpURLConnection.HTTP_BAD_METHOD;
4+
import static java.net.HttpURLConnection.HTTP_OK;
5+
import static java.net.HttpURLConnection.HTTP_UNAVAILABLE;
6+
import static org.assertj.core.api.Assertions.assertThat;
7+
import static org.mockito.ArgumentMatchers.eq;
8+
import static org.mockito.Mockito.mock;
9+
import static org.mockito.Mockito.verify;
10+
import static org.mockito.Mockito.when;
11+
12+
import com.sun.net.httpserver.Headers;
13+
import com.sun.net.httpserver.HttpExchange;
14+
import java.io.ByteArrayOutputStream;
15+
import java.io.IOException;
16+
import java.util.List;
17+
import java.util.function.Supplier;
18+
import org.junit.jupiter.api.Test;
19+
20+
class HealthHandlerTest {
21+
22+
@Test
23+
void getReturns200AndJsonBodyWhenUp() throws IOException {
24+
HealthOutcome outcome = new HealthOutcome("Up", List.of(new Dependency("jdbc", "Up")));
25+
HttpExchange ex = newExchange("GET");
26+
Headers headers = new Headers();
27+
when(ex.getResponseHeaders()).thenReturn(headers);
28+
ByteArrayOutputStream body = new ByteArrayOutputStream();
29+
when(ex.getResponseBody()).thenReturn(body);
30+
31+
Handlers.healthHandler(() -> outcome).handle(ex);
32+
33+
verify(ex).sendResponseHeaders(eq(HTTP_OK), eq((long) body.size()));
34+
assertThat(headers.getFirst("Content-Type")).isEqualTo("application/json");
35+
assertThat(body.toString())
36+
.isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[{\"id\":\"jdbc\",\"status\":\"Up\"}]}");
37+
}
38+
39+
@Test
40+
void getReturns200WithEmptyDependencyArrayWhenNoDeps() throws IOException {
41+
HttpExchange ex = newExchange("GET");
42+
Headers headers = new Headers();
43+
when(ex.getResponseHeaders()).thenReturn(headers);
44+
ByteArrayOutputStream body = new ByteArrayOutputStream();
45+
when(ex.getResponseBody()).thenReturn(body);
46+
47+
Handlers.healthHandler(() -> new HealthOutcome("Up", List.of())).handle(ex);
48+
49+
verify(ex).sendResponseHeaders(eq(HTTP_OK), eq((long) body.size()));
50+
assertThat(body.toString()).isEqualTo("{\"outcome\":\"Up\",\"dependencies\":[]}");
51+
}
52+
53+
@Test
54+
void getReturns503WhenDown() throws IOException {
55+
HealthOutcome outcome = new HealthOutcome("Down", List.of(new Dependency("jdbc", "Down")));
56+
HttpExchange ex = newExchange("GET");
57+
Headers headers = new Headers();
58+
when(ex.getResponseHeaders()).thenReturn(headers);
59+
ByteArrayOutputStream body = new ByteArrayOutputStream();
60+
when(ex.getResponseBody()).thenReturn(body);
61+
62+
Handlers.healthHandler(() -> outcome).handle(ex);
63+
64+
verify(ex).sendResponseHeaders(eq(HTTP_UNAVAILABLE), eq((long) body.size()));
65+
assertThat(headers.getFirst("Content-Type")).isEqualTo("application/json");
66+
assertThat(body.toString()).contains("\"outcome\":\"Down\"");
67+
}
68+
69+
@Test
70+
void headIsAccepted() throws IOException {
71+
HttpExchange ex = newExchange("HEAD");
72+
Headers headers = new Headers();
73+
when(ex.getResponseHeaders()).thenReturn(headers);
74+
when(ex.getResponseBody()).thenReturn(new ByteArrayOutputStream());
75+
76+
Handlers.healthHandler(() -> new HealthOutcome("Up", List.of())).handle(ex);
77+
78+
verify(ex)
79+
.sendResponseHeaders(
80+
eq(HTTP_OK), eq((long) "{\"outcome\":\"Up\",\"dependencies\":[]}".length()));
81+
}
82+
83+
@Test
84+
void postReturns405WithAllowHeader() throws IOException {
85+
HttpExchange ex = newExchange("POST");
86+
Headers headers = new Headers();
87+
when(ex.getResponseHeaders()).thenReturn(headers);
88+
89+
Handlers.healthHandler(() -> new HealthOutcome("Up", List.of())).handle(ex);
90+
91+
verify(ex).sendResponseHeaders(HTTP_BAD_METHOD, -1);
92+
assertThat(headers.getFirst("Allow")).isEqualTo("GET, HEAD");
93+
}
94+
95+
@Test
96+
void runtimeExceptionFromProbeMapsToDown503() throws IOException {
97+
HttpExchange ex = newExchange("GET");
98+
Headers headers = new Headers();
99+
when(ex.getResponseHeaders()).thenReturn(headers);
100+
ByteArrayOutputStream body = new ByteArrayOutputStream();
101+
when(ex.getResponseBody()).thenReturn(body);
102+
103+
Supplier<HealthOutcome> failing =
104+
() -> {
105+
throw new IllegalStateException("boom");
106+
};
107+
Handlers.healthHandler(failing).handle(ex);
108+
109+
verify(ex).sendResponseHeaders(eq(HTTP_UNAVAILABLE), eq((long) body.size()));
110+
assertThat(body.toString()).isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}");
111+
}
112+
113+
@Test
114+
void nullReturnFromProbeMapsToDown503() throws IOException {
115+
HttpExchange ex = newExchange("GET");
116+
Headers headers = new Headers();
117+
when(ex.getResponseHeaders()).thenReturn(headers);
118+
ByteArrayOutputStream body = new ByteArrayOutputStream();
119+
when(ex.getResponseBody()).thenReturn(body);
120+
121+
Handlers.healthHandler(() -> null).handle(ex);
122+
123+
verify(ex).sendResponseHeaders(eq(HTTP_UNAVAILABLE), eq((long) body.size()));
124+
assertThat(body.toString()).isEqualTo("{\"outcome\":\"Down\",\"dependencies\":[]}");
125+
}
126+
127+
private static HttpExchange newExchange(String method) {
128+
HttpExchange ex = mock(HttpExchange.class);
129+
when(ex.getRequestMethod()).thenReturn(method);
130+
when(ex.getResponseHeaders()).thenReturn(new Headers());
131+
return ex;
132+
}
133+
}

0 commit comments

Comments
 (0)