From 472485492ea240fc130a075e2ace169efbb20dc9 Mon Sep 17 00:00:00 2001 From: Markus Malkusch Date: Mon, 6 Nov 2023 22:37:25 +0100 Subject: [PATCH] Refactor --- pom.xml | 4 +- src/main/java/de/malkusch/km200/KM200.java | 142 +++++------------- .../de/malkusch/km200/KM200Exception.java | 8 + .../java/de/malkusch/km200/http/Http.java | 32 ++++ .../java/de/malkusch/km200/http/JdkHttp.java | 77 ++++++++++ .../de/malkusch/km200/http/RetryHttp.java | 51 +++++++ .../malkusch/km200/http/SerializedHttp.java | 27 ++++ .../java/de/malkusch/km200/KM200Test.java | 37 +++++ 8 files changed, 268 insertions(+), 110 deletions(-) create mode 100644 src/main/java/de/malkusch/km200/http/Http.java create mode 100644 src/main/java/de/malkusch/km200/http/JdkHttp.java create mode 100644 src/main/java/de/malkusch/km200/http/RetryHttp.java create mode 100644 src/main/java/de/malkusch/km200/http/SerializedHttp.java diff --git a/pom.xml b/pom.xml index 7baacb1..09e7c7e 100644 --- a/pom.xml +++ b/pom.xml @@ -17,8 +17,8 @@ malkusch/km200 - 16 - 16 + 17 + 17 UTF-8 diff --git a/src/main/java/de/malkusch/km200/KM200.java b/src/main/java/de/malkusch/km200/KM200.java index 5bdbc5f..345747d 100644 --- a/src/main/java/de/malkusch/km200/KM200.java +++ b/src/main/java/de/malkusch/km200/KM200.java @@ -1,19 +1,9 @@ package de.malkusch.km200; -import static java.net.http.HttpClient.newBuilder; -import static java.net.http.HttpClient.Redirect.ALWAYS; import static java.util.Objects.requireNonNull; import java.io.IOException; import java.math.BigDecimal; -import java.net.CookieManager; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpRequest.BodyPublishers; -import java.net.http.HttpResponse; -import java.net.http.HttpResponse.BodyHandler; -import java.net.http.HttpResponse.BodyHandlers; import java.time.Duration; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @@ -24,10 +14,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import de.malkusch.km200.KM200Exception.ServerError; -import dev.failsafe.Failsafe; -import dev.failsafe.FailsafeException; -import dev.failsafe.FailsafeExecutor; -import dev.failsafe.RetryPolicy; +import de.malkusch.km200.http.Http; +import de.malkusch.km200.http.JdkHttp; +import de.malkusch.km200.http.RetryHttp; +import de.malkusch.km200.http.SerializedHttp; /** * This is an API for Bosch/Buderus/Junkers heaters with a KM200 gateway. @@ -66,16 +56,14 @@ public final class KM200 { private final KM200Device device; private final KM200Comm comm; private final ObjectMapper mapper = new ObjectMapper(); - private final HttpClient http; - private final Duration timeout; - private final String uri; - - private final FailsafeExecutor> retryQuery; - private final FailsafeExecutor> retryUpdate; + private final Http queryHttp; + private final Http updateHttp; public static final int RETRY_DEFAULT = 3; public static final int RETRY_DISABLED = 0; + static final String USER_AGENT = "TeleHeater/2.2.3"; + /** * Configure the KM200 API with a default retry of {@link #RETRY_DEFAULT}. * @@ -136,14 +124,21 @@ public KM200(String uri, int retries, Duration timeout, String gatewayPassword, device.setMD5Salt(salt); device.setInited(true); this.device = device; - this.comm = new KM200Comm(); - this.retryQuery = buildRetry(retries, IOException.class, ServerError.class); - this.retryUpdate = buildRetry(retries, ServerError.class); - this.timeout = timeout; - this.uri = uri.replaceAll("/*$", ""); - this.http = newBuilder().connectTimeout(timeout).cookieHandler(new CookieManager()).followRedirects(ALWAYS) - .build(); + + { + Http http = new JdkHttp(uri.replaceAll("/*$", ""), USER_AGENT, timeout); + + /* + * The KM200 itself is not thread safe. This proxy serializes all + * requests to protect users from a wrong concurrent usage of this + * API. + */ + http = new SerializedHttp(http); + + queryHttp = new RetryHttp(http, retries, IOException.class, ServerError.class); + updateHttp = new RetryHttp(http, retries, ServerError.class); + } query("/system"); } @@ -181,6 +176,8 @@ public void update(String path, BigDecimal value) throws KM200Exception, IOExcep } private void update(String path, Object update) throws KM200Exception, IOException, InterruptedException { + assertPath(path); + String json = null; try { json = mapper.writeValueAsString(update); @@ -191,23 +188,18 @@ private void update(String path, Object update) throws KM200Exception, IOExcepti if (encrypted == null) { throw new KM200Exception("Could not encrypt update " + json); } - var request = request(path).POST(BodyPublishers.ofByteArray(encrypted)).build(); - var response = sendWithRetries(retryUpdate, request, BodyHandlers.ofString()); + var response = updateHttp.post(path, encrypted); - if (!(response.statusCode() >= 200 && response.statusCode() < 300)) { + if (!(response.status() >= 200 && response.status() < 300)) { throw new KM200Exception( - String.format("Failed to update %s [%d]: %s", path, response.statusCode(), response.body())); + String.format("Failed to update %s [%d]: %s", path, response.status(), response.body())); } } public String query(String path) throws KM200Exception, IOException, InterruptedException { - assertNotBlank(path, "Path must not be blank"); - if (!path.startsWith("/")) { - throw new IllegalArgumentException("Path must start with a leading /"); - } + assertPath(path); - var request = request(path).GET().build(); - var response = sendWithRetries(retryQuery, request, BodyHandlers.ofByteArray()); + var response = queryHttp.get(path); var encrypted = response.body(); if (encrypted == null) { throw new KM200Exception("No response when querying " + path); @@ -228,57 +220,6 @@ public String query(String path) throws KM200Exception, IOException, Interrupted return decrypted; } - private HttpResponse sendWithRetries(FailsafeExecutor> retry, HttpRequest request, - BodyHandler bodyHandler) throws IOException, InterruptedException { - - try { - return retry.get(() -> send(request, bodyHandler)); - - } catch (FailsafeException e) { - var cause = e.getCause(); - if (cause instanceof IOException) { - throw (IOException) cause; - - } else if (cause instanceof InterruptedException) { - throw (InterruptedException) cause; - - } else if (cause instanceof KM200Exception) { - throw (KM200Exception) cause; - - } else { - throw e; - } - } - } - - private final Object serializedSendLock = new Object(); - - private HttpResponse send(HttpRequest request, BodyHandler bodyHandler) - throws IOException, InterruptedException, KM200Exception { - - /* - * The KM200 itself is not thread safe. This lock serializes all - * requests to protect users from a wrong concurrent usage of this API. - */ - synchronized (serializedSendLock) { - var response = http.send(request, bodyHandler); - var status = response.statusCode(); - - if (status >= 200 && status <= 299) { - return response; - - } else { - throw switch (status) { - case 403 -> new KM200Exception.Forbidden(request.uri() + " is forbidden"); - case 404 -> new KM200Exception.NotFound(request.uri() + " was not found"); - case 423 -> new KM200Exception.Locked(request.uri() + " was locked"); - case 500 -> new KM200Exception.ServerError(request.uri() + " resulted in a server error"); - default -> new KM200Exception(request.uri() + " failed with response code " + status); - }; - } - } - } - public double queryDouble(String path) throws KM200Exception, IOException, InterruptedException { var json = queryJson(path); return json.get("value").asDouble(); @@ -302,26 +243,11 @@ private JsonNode queryJson(String path) throws KM200Exception, IOException, Inte } } - static final String USER_AGENT = "TeleHeater/2.2.3"; - - private HttpRequest.Builder request(String path) { - return HttpRequest.newBuilder(URI.create(uri + path)) // - .setHeader("User-Agent", USER_AGENT) // - .setHeader("Accept", "application/json") // - .timeout(timeout); - } - - private static final Duration RETRY_DELAY_MIN = Duration.ofSeconds(1); - private static final Duration RETRY_DELAY_MAX = Duration.ofSeconds(2); - - @SafeVarargs - private static FailsafeExecutor buildRetry(int retries, Class... exceptions) { - return Failsafe.with( // - RetryPolicy. builder() // - .handle(exceptions) // - .withMaxRetries(retries) // - .withDelay(RETRY_DELAY_MIN, RETRY_DELAY_MAX) // - .build()); + private static void assertPath(String path) { + assertNotBlank(path, "Path must not be blank"); + if (!path.startsWith("/")) { + throw new IllegalArgumentException("Path must start with a leading /"); + } } private static void assertNotBlank(String var, String message) { diff --git a/src/main/java/de/malkusch/km200/KM200Exception.java b/src/main/java/de/malkusch/km200/KM200Exception.java index 9969247..9644fb7 100644 --- a/src/main/java/de/malkusch/km200/KM200Exception.java +++ b/src/main/java/de/malkusch/km200/KM200Exception.java @@ -27,6 +27,14 @@ public ServerError(String message) { } } + public static class BadRequest extends KM200Exception { + private static final long serialVersionUID = -6497781961251724723L; + + public BadRequest(String message) { + super(message); + } + } + public static class Forbidden extends KM200Exception { private static final long serialVersionUID = -6497781961251724723L; diff --git a/src/main/java/de/malkusch/km200/http/Http.java b/src/main/java/de/malkusch/km200/http/Http.java new file mode 100644 index 0000000..ee6b0f1 --- /dev/null +++ b/src/main/java/de/malkusch/km200/http/Http.java @@ -0,0 +1,32 @@ +package de.malkusch.km200.http; + +import java.io.IOException; + +import de.malkusch.km200.KM200Exception; + +public abstract class Http { + + sealed interface Request permits Request.Get, Request.Post { + + String path(); + + static record Get(String path) implements Request { + } + + static record Post(String path, byte[] body) implements Request { + } + } + + public static record Response(int status, byte[] body) { + } + + protected abstract Response send(Request request) throws IOException, InterruptedException, KM200Exception; + + public final Response get(String path) throws KM200Exception, IOException, InterruptedException { + return send(new Request.Get(path)); + } + + public final Response post(String path, byte[] body) throws KM200Exception, IOException, InterruptedException { + return send(new Request.Post(path, body)); + } +} diff --git a/src/main/java/de/malkusch/km200/http/JdkHttp.java b/src/main/java/de/malkusch/km200/http/JdkHttp.java new file mode 100644 index 0000000..c195fd8 --- /dev/null +++ b/src/main/java/de/malkusch/km200/http/JdkHttp.java @@ -0,0 +1,77 @@ +package de.malkusch.km200.http; + +import static java.net.http.HttpClient.newBuilder; +import static java.net.http.HttpClient.Redirect.ALWAYS; +import static java.net.http.HttpRequest.BodyPublishers.ofByteArray; + +import java.io.IOException; +import java.net.CookieManager; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.time.Duration; + +import de.malkusch.km200.KM200Exception; +import de.malkusch.km200.http.Http.Request.Get; +import de.malkusch.km200.http.Http.Request.Post; + +public final class JdkHttp extends Http { + + private final HttpClient client; + private final String uri; + private final String userAgent; + private final Duration timeout; + + public JdkHttp(String uri, String userAgent, Duration timeout) { + this.uri = uri; + this.userAgent = userAgent; + this.timeout = timeout; + + this.client = newBuilder() // + .connectTimeout(timeout) // + .cookieHandler(new CookieManager()) // + .followRedirects(ALWAYS) // + .build(); + } + + @Override + public Response send(Request request) throws IOException, InterruptedException, KM200Exception { + var httpRequest = httpRequest(request); + var response = client.send(httpRequest, BodyHandlers.ofByteArray()); + var status = response.statusCode(); + + if (status >= 200 && status <= 299) { + return new Response(status, response.body()); + + } else { + throw switch (status) { + case 400 -> new KM200Exception.BadRequest("Bad request to " + request.path()); + case 403 -> new KM200Exception.Forbidden(request.path() + " is forbidden"); + case 404 -> new KM200Exception.NotFound(request.path() + " was not found"); + case 423 -> new KM200Exception.Locked(request.path() + " was locked"); + case 500 -> new KM200Exception.ServerError(request.path() + " resulted in a server error"); + default -> new KM200Exception(request.path() + " failed with response code " + status); + }; + } + } + + private HttpRequest httpRequest(Request request) { + var builder = HttpRequest.newBuilder(URI.create(uri + request.path())) // + .setHeader("User-Agent", userAgent) // + .setHeader("Accept", "application/json") // + .timeout(timeout); + + if (request instanceof Get) { + builder.GET(); + + } else if (request instanceof Post post) { + builder.POST(ofByteArray(post.body())); + + } else { + throw new IllegalStateException(); + } + + return builder.build(); + } +} diff --git a/src/main/java/de/malkusch/km200/http/RetryHttp.java b/src/main/java/de/malkusch/km200/http/RetryHttp.java new file mode 100644 index 0000000..657648f --- /dev/null +++ b/src/main/java/de/malkusch/km200/http/RetryHttp.java @@ -0,0 +1,51 @@ +package de.malkusch.km200.http; + +import java.io.IOException; +import java.time.Duration; + +import de.malkusch.km200.KM200Exception; +import dev.failsafe.Failsafe; +import dev.failsafe.FailsafeException; +import dev.failsafe.FailsafeExecutor; +import dev.failsafe.RetryPolicy; + +public final class RetryHttp extends Http { + + private static final Duration RETRY_DELAY_MIN = Duration.ofSeconds(1); + private static final Duration RETRY_DELAY_MAX = Duration.ofSeconds(2); + + private final Http http; + private final FailsafeExecutor retry; + + @SafeVarargs + public RetryHttp(Http http, int retries, Class... exceptions) { + this.http = http; + this.retry = Failsafe.with( // + RetryPolicy. builder() // + .handle(exceptions) // + .withMaxRetries(retries) // + .withDelay(RETRY_DELAY_MIN, RETRY_DELAY_MAX) // + .build()); + } + + @Override + public Response send(Request request) throws IOException, InterruptedException, KM200Exception { + try { + return retry.get(() -> http.send(request)); + + } catch (FailsafeException e) { + if (e.getCause() instanceof IOException cause) { + throw cause; + + } else if (e.getCause() instanceof InterruptedException cause) { + throw cause; + + } else if (e.getCause() instanceof KM200Exception cause) { + throw cause; + + } else { + throw new KM200Exception("Unexpected retry error for " + request.path(), e); + } + } + } +} diff --git a/src/main/java/de/malkusch/km200/http/SerializedHttp.java b/src/main/java/de/malkusch/km200/http/SerializedHttp.java new file mode 100644 index 0000000..0e26b48 --- /dev/null +++ b/src/main/java/de/malkusch/km200/http/SerializedHttp.java @@ -0,0 +1,27 @@ +package de.malkusch.km200.http; + +import java.io.IOException; +import java.util.concurrent.locks.ReentrantLock; + +import de.malkusch.km200.KM200Exception; + +public final class SerializedHttp extends Http { + + private final Http http; + private final ReentrantLock lock = new ReentrantLock(); + + public SerializedHttp(Http http) { + this.http = http; + } + + @Override + public Response send(Request request) throws IOException, InterruptedException, KM200Exception { + lock.lockInterruptibly(); + try { + return http.send(request); + + } finally { + lock.unlock(); + } + } +} diff --git a/src/test/java/de/malkusch/km200/KM200Test.java b/src/test/java/de/malkusch/km200/KM200Test.java index 41f5cac..4c25cee 100644 --- a/src/test/java/de/malkusch/km200/KM200Test.java +++ b/src/test/java/de/malkusch/km200/KM200Test.java @@ -18,6 +18,7 @@ import static com.google.common.net.HttpHeaders.LOCATION; import static de.malkusch.km200.KM200.RETRY_DISABLED; import static de.malkusch.km200.KM200.USER_AGENT; +import static java.lang.Thread.currentThread; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static org.apache.commons.io.IOUtils.resourceToString; @@ -139,6 +140,26 @@ public void updateShouldEncrypt() throws Exception { .withRequestBody(equalTo("5xIVJSMa037r4XkbMhFnkgKrnu4nsjb9+oeBkEwVIj8="))); } + @Test + public void queryShouldInterrupt() throws Exception { + stubFor(get("/interrupt").willReturn(ok(loadBody("gateway.DateTime")))); + var km200 = new KM200(URI, TIMEOUT, GATEWAY_PASSWORD, PRIVATE_PASSWORD, SALT); + + currentThread().interrupt(); + + assertThrows(InterruptedException.class, () -> km200.queryString("/interrupt")); + } + + @Test + public void updateShouldInterrupt() throws Exception { + stubFor(post("/update-interrupt").willReturn(ok())); + var km200 = new KM200(URI, TIMEOUT, GATEWAY_PASSWORD, PRIVATE_PASSWORD, SALT); + + currentThread().interrupt(); + + assertThrows(InterruptedException.class, () -> km200.update("/update-interrupt", 42)); + } + @Test public void queryShouldFailOnNonExistingPath() throws Exception { stubFor(get("/non-existing").willReturn(notFound())); @@ -171,6 +192,22 @@ public void updateShouldFailOnForbiddenPath() throws Exception { assertThrows(KM200Exception.Forbidden.class, () -> km200.update("/update-forbidden", 42)); } + @Test + public void queryShouldFailOnBadRequest() throws Exception { + stubFor(get("/bad").willReturn(status(400))); + var km200 = new KM200(URI, TIMEOUT, GATEWAY_PASSWORD, PRIVATE_PASSWORD, SALT); + + assertThrows(KM200Exception.BadRequest.class, () -> km200.queryString("/bad")); + } + + @Test + public void updateShouldFailOnBadRequest() throws Exception { + stubFor(post("/bad").willReturn(status(400))); + var km200 = new KM200(URI, TIMEOUT, GATEWAY_PASSWORD, PRIVATE_PASSWORD, SALT); + + assertThrows(KM200Exception.BadRequest.class, () -> km200.update("/bad", 42)); + } + @Test public void queryShouldFailOnLocked() throws Exception { stubFor(get("/locked").willReturn(status(423)));