From 08f7bb8a8f2fd3798e92c56514c2189a62f3c664 Mon Sep 17 00:00:00 2001 From: Mariusz Pacek Date: Sat, 24 Oct 2026 00:33:42 +0200 Subject: [PATCH] feat(backend): add GatewayHttpClient for /health and /snapshot 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 --- .../adapters/gateway/GatewayHttpClient.java | 88 ++++++++++ .../gateway/GatewayHttpClientTest.java | 160 ++++++++++++++++++ .../test/resources/gateway/health-all-up.json | 1 + .../gateway/health-never-succeeded.json | 1 + .../resources/gateway/health-one-down.json | 1 + .../resources/gateway/snapshot-all-up.json | 2 + .../gateway/snapshot-never-succeeded.json | 1 + .../resources/gateway/snapshot-one-down.json | 2 + 8 files changed, 256 insertions(+) create mode 100644 backend/src/main/java/home/backend/adapters/gateway/GatewayHttpClient.java create mode 100644 backend/src/test/java/home/backend/adapters/gateway/GatewayHttpClientTest.java create mode 100644 backend/src/test/resources/gateway/health-all-up.json create mode 100644 backend/src/test/resources/gateway/health-never-succeeded.json create mode 100644 backend/src/test/resources/gateway/health-one-down.json create mode 100644 backend/src/test/resources/gateway/snapshot-all-up.json create mode 100644 backend/src/test/resources/gateway/snapshot-never-succeeded.json create mode 100644 backend/src/test/resources/gateway/snapshot-one-down.json diff --git a/backend/src/main/java/home/backend/adapters/gateway/GatewayHttpClient.java b/backend/src/main/java/home/backend/adapters/gateway/GatewayHttpClient.java new file mode 100644 index 0000000..d905342 --- /dev/null +++ b/backend/src/main/java/home/backend/adapters/gateway/GatewayHttpClient.java @@ -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 devices) { + } + + private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) { + } + + private record DeviceReadings(String name, List readings) { + } + + private record Reading(String database, String measurement, Map 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> 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 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); + } + } +} diff --git a/backend/src/test/java/home/backend/adapters/gateway/GatewayHttpClientTest.java b/backend/src/test/java/home/backend/adapters/gateway/GatewayHttpClientTest.java new file mode 100644 index 0000000..3e1f212 --- /dev/null +++ b/backend/src/test/java/home/backend/adapters/gateway/GatewayHttpClientTest.java @@ -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, "not json"); + + 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); + } +} diff --git a/backend/src/test/resources/gateway/health-all-up.json b/backend/src/test/resources/gateway/health-all-up.json new file mode 100644 index 0000000..40cc6e2 --- /dev/null +++ b/backend/src/test/resources/gateway/health-all-up.json @@ -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}]} diff --git a/backend/src/test/resources/gateway/health-never-succeeded.json b/backend/src/test/resources/gateway/health-never-succeeded.json new file mode 100644 index 0000000..1563f6d --- /dev/null +++ b/backend/src/test/resources/gateway/health-never-succeeded.json @@ -0,0 +1 @@ +{"status":"DOWN","devices":[{"name":"heat-pump","status":"DOWN","lastSuccess":null,"lastError":"connection refused"}]} diff --git a/backend/src/test/resources/gateway/health-one-down.json b/backend/src/test/resources/gateway/health-one-down.json new file mode 100644 index 0000000..bc2f21a --- /dev/null +++ b/backend/src/test/resources/gateway/health-one-down.json @@ -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"}]} diff --git a/backend/src/test/resources/gateway/snapshot-all-up.json b/backend/src/test/resources/gateway/snapshot-all-up.json new file mode 100644 index 0000000..e3a1283 --- /dev/null +++ b/backend/src/test/resources/gateway/snapshot-all-up.json @@ -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"}]}] diff --git a/backend/src/test/resources/gateway/snapshot-never-succeeded.json b/backend/src/test/resources/gateway/snapshot-never-succeeded.json new file mode 100644 index 0000000..bf0f1da --- /dev/null +++ b/backend/src/test/resources/gateway/snapshot-never-succeeded.json @@ -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":[]}] diff --git a/backend/src/test/resources/gateway/snapshot-one-down.json b/backend/src/test/resources/gateway/snapshot-one-down.json new file mode 100644 index 0000000..bc42cd9 --- /dev/null +++ b/backend/src/test/resources/gateway/snapshot-one-down.json @@ -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"}]}]