feat(backend): add GatewayHttpClient for /health and /snapshot
CI / Backend tests (Maven / Java 21) (push) Successful in 54s

The backend reads live state from the gateway over HTTP. /health says
which device is up (503 is a valid body), /snapshot carries the
readings; both are merged per device. Unknown JSON fields are ignored.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5.5 committed 2026-10-24 00:33:42 +02:00
1 parent 51a68f0d44
commit 08f7bb8a8f
8 files changed
+256

No files matched your search

@@ -0,0 +1,88 @@
package home.backend.adapters.gateway;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import home.backend.application.GatewayClient;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
public class GatewayHttpClient implements GatewayClient {
private record Health(List<DeviceHealth> devices) {
}
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
}
private record DeviceReadings(String name, List<Reading> readings) {
}
private record Reading(String database, String measurement, Map<String, Double> fields, Instant time) {
}
// Unknown properties are ignored so the gateway can add fields without breaking us.
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final URI baseUri;
private final Duration timeout;
private final HttpClient http;
public GatewayHttpClient(URI baseUri, Duration timeout) {
this.baseUri = baseUri;
this.timeout = timeout;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
}
@Override
public GatewaySnapshot snapshot() throws IOException {
var health = JSON.readValue(get("/health", true), Health.class);
var readings = JSON.readValue(get("/snapshot", false), DeviceReadings[].class);
Map<String, List<Measurement>> measurements = Arrays.stream(readings)
.collect(Collectors.toMap(DeviceReadings::name, GatewayHttpClient::measurements));
var devices = health.devices().stream()
.map(device -> new DeviceSnapshot(device.name(), device.status().equals("UP"),
device.lastSuccess(), device.lastError(),
measurements.getOrDefault(device.name(), List.of())))
.toList();
return new GatewaySnapshot(devices);
}
private static List<Measurement> measurements(DeviceReadings device) {
return device.readings().stream()
.map(r -> new Measurement(r.database(), r.measurement(), r.fields(), r.time()))
.toList();
}
/** A gateway with a device down answers /health with 503 and a valid body. */
private byte[] get(String path, boolean downIsValid) throws IOException {
var request = HttpRequest.newBuilder(baseUri.resolve(path)).timeout(timeout).GET().build();
try {
var response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
int status = response.statusCode();
if (status != 200 && !(downIsValid && status == 503)) {
throw new IOException("gateway " + path + " answered " + status);
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("interrupted while calling gateway " + path, e);
}
}
}
@@ -0,0 +1,160 @@
package home.backend.adapters.gateway;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class GatewayHttpClientTest {
private HttpServer server;
private GatewayHttpClient client;
@BeforeEach
void startServer() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.start();
client = new GatewayHttpClient(URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
Duration.ofMillis(500));
}
@AfterEach
void stopServer() {
server.stop(0);
}
private void serve(String path, int status, String body) {
server.createContext(path, exchange -> {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
}
private void serveFiles(int healthStatus, String healthFile, String snapshotFile) throws IOException {
serve("/health", healthStatus, resource(healthFile));
serve("/snapshot", 200, resource(snapshotFile));
}
private static String resource(String name) throws IOException {
return Files.readString(Path.of("src/test/resources/gateway", name));
}
@Test
void mapsEveryDeviceWithItsMeasurements() throws Exception {
serveFiles(200, "health-all-up.json", "snapshot-all-up.json");
var devices = client.snapshot().devices();
assertEquals(2, devices.size());
DeviceSnapshot pump = devices.get(0);
assertEquals("heat-pump", pump.name());
assertTrue(pump.up());
assertEquals(Instant.parse("2026-10-24T10:00:00Z"), pump.lastSuccess());
assertNull(pump.lastError());
var measurement = pump.measurements().get(0);
assertEquals("Dom_Pompa", measurement.database());
assertEquals("POMPA", measurement.measurement());
assertEquals(Map.of("T_zasilanie", 35.5, "T_powrot", 30.0), measurement.fields());
assertEquals(Instant.parse("2026-10-24T10:00:00Z"), measurement.time());
}
@Test
void aDownDeviceAnswering503IsStillASnapshot() throws Exception {
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
var devices = client.snapshot().devices();
assertTrue(devices.get(0).up());
DeviceSnapshot meter = devices.get(1);
assertFalse(meter.up());
assertEquals("no route to host", meter.lastError());
assertEquals(Instant.parse("2026-10-24T10:00:00Z"), meter.lastSuccess());
assertEquals(1200.0, meter.measurements().get(0).fields().get("power_w"));
}
@Test
void aDeviceThatNeverSucceededHasNoLastSuccessAndNoMeasurements() throws Exception {
serveFiles(503, "health-never-succeeded.json", "snapshot-never-succeeded.json");
var pump = client.snapshot().devices().get(0);
assertFalse(pump.up());
assertNull(pump.lastSuccess());
assertEquals("connection refused", pump.lastError());
assertTrue(pump.measurements().isEmpty());
}
@Test
void ignoresPropertiesItDoesNotKnow() throws Exception {
serve("/health", 200, """
{"status":"UP","version":"2","devices":[{"name":"x","status":"UP","lastSuccess":null,"lastError":null,"extra":1}]}""");
serve("/snapshot", 200, """
[{"name":"x","new":true,"readings":[{"database":"d","measurement":"m","tags":{},
"fields":{"a":1.0},"time":"2026-10-24T10:00:00Z","unit":"C"}]}]""");
assertEquals(1, client.snapshot().devices().get(0).measurements().size());
}
@Test
void failsWhenSnapshotAnswers503() throws Exception {
serve("/health", 200, resource("health-all-up.json"));
serve("/snapshot", 503, "[]");
assertThrows(IOException.class, client::snapshot);
}
@Test
void failsOnServerError() {
serve("/health", 500, "{\"error\":\"boom\"}");
assertThrows(IOException.class, client::snapshot);
}
@Test
void failsOnBadJson() {
serve("/health", 200, "<html>not json</html>");
assertThrows(IOException.class, client::snapshot);
}
@Test
void failsWhenTheGatewayIsSlowerThanTheTimeout() {
server.createContext("/health", exchange -> {
try {
Thread.sleep(2000);
} catch (InterruptedException ignored) {
// the client gave up; nothing to answer
}
exchange.close();
});
assertThrows(HttpTimeoutException.class, client::snapshot);
}
@Test
void failsWhenTheGatewayIsUnreachable() {
server.stop(0);
assertThrows(IOException.class, client::snapshot);
}
}
@@ -0,0 +1 @@
{"status":"UP","devices":[{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-24T10:00:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-24T10:00:00Z","lastError":null}]}
@@ -0,0 +1 @@
{"status":"DOWN","devices":[{"name":"heat-pump","status":"DOWN","lastSuccess":null,"lastError":"connection refused"}]}
@@ -0,0 +1 @@
{"status":"DOWN","devices":[{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-24T10:00:30Z","lastError":null},{"name":"house-meter","status":"DOWN","lastSuccess":"2026-10-24T10:00:00Z","lastError":"no route to host"}]}
@@ -0,0 +1,2 @@
[{"name":"heat-pump","interval":"PT10S","since":"2026-10-24T09:00:00Z","lastAttempt":"2026-10-24T10:00:00Z","lastSuccess":"2026-10-24T10:00:00Z","lastError":null,"readings":[{"database":"Dom_Pompa","measurement":"POMPA","tags":{"device":"frisko"},"fields":{"T_zasilanie":35.5,"T_powrot":30.0},"time":"2026-10-24T10:00:00Z"}]},
{"name":"house-meter","interval":"PT10S","since":"2026-10-24T09:00:00Z","lastAttempt":"2026-10-24T10:00:00Z","lastSuccess":"2026-10-24T10:00:00Z","lastError":null,"readings":[{"database":"energy","measurement":"house","tags":{},"fields":{"power_w":1234.5},"time":"2026-10-24T10:00:00Z"}]}]
@@ -0,0 +1 @@
[{"name":"heat-pump","interval":"PT10S","since":"2026-10-24T09:00:00Z","lastAttempt":"2026-10-24T10:00:00Z","lastSuccess":null,"lastError":"connection refused","readings":[]}]
@@ -0,0 +1,2 @@
[{"name":"heat-pump","interval":"PT10S","since":"2026-10-24T09:00:00Z","lastAttempt":"2026-10-24T10:00:30Z","lastSuccess":"2026-10-24T10:00:30Z","lastError":null,"readings":[{"database":"Dom_Pompa","measurement":"POMPA","tags":{},"fields":{"T_zasilanie":36.0},"time":"2026-10-24T10:00:30Z"}]},
{"name":"house-meter","interval":"PT10S","since":"2026-10-24T09:00:00Z","lastAttempt":"2026-10-24T10:00:30Z","lastSuccess":"2026-10-24T10:00:00Z","lastError":"no route to host","readings":[{"database":"energy","measurement":"house","tags":{},"fields":{"power_w":1200.0},"time":"2026-10-24T10:00:00Z"}]}]