feat(backend): add GatewayHttpClient for /health and /snapshot
CI / Backend tests (Maven / Java 21) (push) Successful in 54s
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:
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"}]}]
|
||||
Reference in new issue
Block a user