diff --git a/build.gradle b/build.gradle index 6be3d73..88f2c57 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,8 @@ repositories { } dependencies { + implementation 'tools.jackson.core:jackson-databind:3.0.1' + testImplementation platform('org.junit:junit-bom:5.14.3') testImplementation 'org.junit.jupiter:junit-jupiter' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/docs/plans/2026-04-10-http-client-design.md b/docs/plans/2026-04-10-http-client-design.md new file mode 100644 index 0000000..a5c184e --- /dev/null +++ b/docs/plans/2026-04-10-http-client-design.md @@ -0,0 +1,150 @@ +# HTTP Client Infrastructure Design + +**Issue:** #11 +**Date:** 2026-04-10 +**Status:** Accepted + +## Summary + +Internal HTTP client wrapping `java.net.http.HttpClient` for all outgoing Montonio API calls. Synchronous, JSON-based, no framework dependencies. Uses Jackson 3 for serialization. + +## Design Decisions + +| # | Decision | Choice | Rationale | +|---|------------------------------|-----------------------------------------------------|---------------------------------------------------------------| +| 1 | Consumer-supplied HttpClient | No, internal only | Keep it simple; consumer injection deferred to a future issue | +| 2 | JSON library | Jackson 3 (`tools.jackson.core:jackson-databind`) | Industry standard, Lombok-friendly, modern module system | +| 3 | HTTP methods | GET and POST only | YAGNI; covers all known Montonio endpoints | +| 4 | Request/response logging | Out of scope | Moved to a separate issue to keep this focused | +| 5 | Method signatures | Generic type-safe (` T get(path, responseType)`) | Callers get deserialized objects; no boilerplate | +| 6 | Configuration | Accept `MontonioSdkConfiguration` directly | Internal component; simple constructor, no decoupling needed | +| 7 | Visibility | Public class, public constructor | Revisit when top-level SDK entry point is designed | + +## Public API + +```java +package ee.bitweb.montonio.sdk.http; + +public class MontonioHttpClient { + + public MontonioHttpClient(MontonioSdkConfiguration configuration) { ... } + + public T get(String path, Class responseType) { ... } + + public T post(String path, Object body, Class responseType) { ... } +} +``` + +### Constructor + +Creates a `java.net.http.HttpClient` with `connectTimeout` from configuration. Creates a Jackson `ObjectMapper` configured to ignore unknown properties. + +### Parameters + +- **`path`** — relative path (e.g., `/orders`, `/orders/{uuid}`). Prepended with `baseUrl` from configuration. +- **`body`** — request body object, serialized to JSON via Jackson. +- **`responseType`** — target class for JSON deserialization of the response body. + +### Headers + +All requests include: +- `Content-Type: application/json` +- `Accept: application/json` + +### Timeout + +`requestTimeout` from configuration applied per-request via `HttpRequest.Builder.timeout()`. + +### Authentication + +Out of scope. JWT `Authorization` header will be added when the authentication issue is implemented. + +## Internal Implementation + +### Fields + +```java +private final HttpClient httpClient; +private final ObjectMapper objectMapper; +private final MontonioSdkConfiguration configuration; +``` + +### HttpClient Construction + +```java +this.httpClient = HttpClient.newBuilder() + .connectTimeout(configuration.getConnectTimeout()) + .build(); +``` + +### Response Handling Flow + +1. Send request via `httpClient.send()`, get body as `String` +2. If 2xx — deserialize body to `responseType` using `objectMapper` +3. If 4xx/5xx — best-effort parse of error body, throw `MontonioApiException` +4. `IOException` — wrap in `MontonioNetworkException` +5. `InterruptedException` — restore interrupt flag, wrap in `MontonioNetworkException` +6. Jackson deserialization failure on 2xx — wrap in `MontonioException` + +## Error Response Parsing + +Best-effort extraction of structured error fields from non-2xx responses: + +```java +private MontonioApiException buildApiException(int statusCode, String responseBody) { + try { + JsonNode node = objectMapper.readTree(responseBody); + String errorCode = optionalText(node, "errorCode"); + String errorMessage = optionalText(node, "message"); + return new MontonioApiException(statusCode, errorCode, errorMessage); + } catch (Exception e) { + return new MontonioApiException(statusCode, null, responseBody); + } +} +``` + +If the body is not valid JSON, falls back to raw body as `errorMessage`. If the JSON parses but lacks the expected fields, null is returned for those fields. Never throws — always produces a usable exception. + +Field names (`errorCode`, `message`) may need adjustment once validated against the real Montonio API. + +## Dependencies + +```groovy +implementation 'tools.jackson.core:jackson-databind:3.0.1' +``` + +Jackson 3 uses the `tools.jackson` group ID. The core module pulls in `jackson-core` and `jackson-annotations` transitively. + +## Testing Strategy + +### Test Infrastructure + +Package-private constructor accepts an `HttpClient` instance for testing: + +```java +MontonioHttpClient(MontonioSdkConfiguration configuration, HttpClient httpClient) { ... } +``` + +The public constructor creates its own `HttpClient`; tests use the package-private one to inject stubs. + +### Test Cases + +| Test | Expected | +|---------------------------|-------------------------------------------------------------------| +| Successful GET | Deserializes response body to target type | +| Successful POST | Serializes request body, deserializes response | +| 4xx with JSON error body | `MontonioApiException` with parsed `errorCode` and `errorMessage` | +| 5xx with non-JSON body | `MontonioApiException` with raw body as `errorMessage` | +| Connection failure | `MontonioNetworkException` | +| Request timeout | `MontonioNetworkException` | +| Malformed JSON on 2xx | `MontonioException` | +| Path appended to base URL | URI = `baseUrl + path` | + +All unit tests — no real HTTP calls. Target near-perfect coverage. + +## Out of Scope + +- Consumer-supplied `HttpClient` injection (future issue) +- Request/response logging (future issue) +- JWT authentication header (future issue) +- PUT, DELETE, PATCH methods (add when needed) diff --git a/src/main/java/ee/bitweb/montonio/sdk/http/MontonioHttpClient.java b/src/main/java/ee/bitweb/montonio/sdk/http/MontonioHttpClient.java new file mode 100644 index 0000000..6438aeb --- /dev/null +++ b/src/main/java/ee/bitweb/montonio/sdk/http/MontonioHttpClient.java @@ -0,0 +1,122 @@ +package ee.bitweb.montonio.sdk.http; + +import ee.bitweb.montonio.sdk.MontonioSdkConfiguration; +import ee.bitweb.montonio.sdk.exception.MontonioApiException; +import ee.bitweb.montonio.sdk.exception.MontonioException; +import ee.bitweb.montonio.sdk.exception.MontonioNetworkException; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +public class MontonioHttpClient { + + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private final MontonioSdkConfiguration configuration; + + public MontonioHttpClient(MontonioSdkConfiguration configuration) { + this( + configuration, + HttpClient.newBuilder() + .connectTimeout(configuration.getConnectTimeout()) + .build() + ); + } + + MontonioHttpClient(MontonioSdkConfiguration configuration, HttpClient httpClient) { + this.configuration = configuration; + this.httpClient = httpClient; + this.objectMapper = JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .build(); + } + + public T get(String path, Class responseType) { + HttpRequest request = newRequestBuilder(path) + .GET() + .build(); + + return execute(request, responseType); + } + + public T post(String path, Object body, Class responseType) { + String json = serialize(body); + + HttpRequest request = newRequestBuilder(path) + .POST(HttpRequest.BodyPublishers.ofString(json)) + .build(); + + return execute(request, responseType); + } + + private HttpRequest.Builder newRequestBuilder(String path) { + return HttpRequest.newBuilder() + .uri(URI.create(configuration.getBaseUrl() + path)) + .timeout(configuration.getRequestTimeout()) + .header("Content-Type", "application/json") + .header("Accept", "application/json"); + } + + private T execute(HttpRequest request, Class responseType) { + HttpResponse response; + try { + response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (IOException e) { + throw new MontonioNetworkException("Request failed: " + e.getMessage(), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new MontonioNetworkException("Request interrupted", e); + } + + int statusCode = response.statusCode(); + String responseBody = response.body(); + + if (statusCode >= 200 && statusCode < 300) { + return deserialize(responseBody, responseType); + } + + throw buildApiException(statusCode, responseBody); + } + + private String serialize(Object body) { + try { + return objectMapper.writeValueAsString(body); + } catch (Exception e) { + throw new MontonioException("Failed to serialize request body", e); + } + } + + private T deserialize(String json, Class responseType) { + try { + return objectMapper.readValue(json, responseType); + } catch (Exception e) { + throw new MontonioException("Failed to deserialize response body", e); + } + } + + private MontonioApiException buildApiException(int statusCode, String responseBody) { + try { + JsonNode node = objectMapper.readTree(responseBody); + String errorCode = optionalText(node, "errorCode"); + String errorMessage = optionalText(node, "message"); + return new MontonioApiException(statusCode, errorCode, errorMessage); + } catch (Exception e) { + return new MontonioApiException(statusCode, null, responseBody); + } + } + + private static String optionalText(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || value.isNull()) { + return null; + } + return value.stringValue(); + } +} diff --git a/src/test/java/ee/bitweb/montonio/sdk/http/MontonioHttpClientTest.java b/src/test/java/ee/bitweb/montonio/sdk/http/MontonioHttpClientTest.java new file mode 100644 index 0000000..7af0c2e --- /dev/null +++ b/src/test/java/ee/bitweb/montonio/sdk/http/MontonioHttpClientTest.java @@ -0,0 +1,356 @@ +package ee.bitweb.montonio.sdk.http; + +import ee.bitweb.montonio.sdk.MontonioSdkConfiguration; +import ee.bitweb.montonio.sdk.exception.MontonioApiException; +import ee.bitweb.montonio.sdk.exception.MontonioException; +import ee.bitweb.montonio.sdk.exception.MontonioNetworkException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.SSLSession; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +class MontonioHttpClientTest { + + private static final String BASE_URL = "https://api.example.com"; + + private MontonioSdkConfiguration configuration; + + @BeforeEach + void setUp() { + configuration = MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .baseUrl(BASE_URL) + .connectTimeout(Duration.ofSeconds(5)) + .requestTimeout(Duration.ofSeconds(10)) + .build(); + } + + @Test + void getDeserializesSuccessfulResponse() { + HttpClient stubClient = new StubHttpClient(200, "{\"name\":\"test\",\"value\":42}"); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + + TestResponse response = client.get("/test", TestResponse.class); + + assertEquals("test", response.name); + assertEquals(42, response.value); + } + + @Test + void getBuildsCorrectUri() { + StubHttpClient stubClient = new StubHttpClient(200, "{\"name\":\"test\",\"value\":1}"); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + + client.get("/orders/123", TestResponse.class); + + assertEquals(URI.create(BASE_URL + "/orders/123"), stubClient.capturedRequest.uri()); + } + + @Test + void getSetsJsonHeaders() { + StubHttpClient stubClient = new StubHttpClient(200, "{\"name\":\"test\",\"value\":1}"); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + + client.get("/test", TestResponse.class); + + HttpHeaders headers = stubClient.capturedRequest.headers(); + assertEquals(List.of("application/json"), headers.allValues("Content-Type")); + assertEquals(List.of("application/json"), headers.allValues("Accept")); + } + + @Test + void getSetsRequestTimeout() { + StubHttpClient stubClient = new StubHttpClient(200, "{\"name\":\"test\",\"value\":1}"); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + + client.get("/test", TestResponse.class); + + assertEquals( + Optional.of(Duration.ofSeconds(10)), + stubClient.capturedRequest.timeout() + ); + } + + @Test + void getUsesGetMethod() { + StubHttpClient stubClient = new StubHttpClient(200, "{\"name\":\"test\",\"value\":1}"); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + + client.get("/test", TestResponse.class); + + assertEquals("GET", stubClient.capturedRequest.method()); + } + + @Test + void postSerializesBodyAndDeserializesResponse() { + StubHttpClient stubClient = new StubHttpClient(200, "{\"name\":\"created\",\"value\":99}"); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + + TestResponse response = client.post("/test", new TestRequest("hello"), TestResponse.class); + + assertEquals("created", response.name); + assertEquals(99, response.value); + assertEquals("{\"data\":\"hello\"}", extractRequestBody(stubClient.capturedRequest)); + } + + @Test + void postUsesPostMethod() { + StubHttpClient stubClient = new StubHttpClient(200, "{\"name\":\"test\",\"value\":1}"); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + + client.post("/test", new TestRequest("hello"), TestResponse.class); + + assertEquals("POST", stubClient.capturedRequest.method()); + } + + @Test + void errorResponseWithJsonBodyThrowsApiExceptionWithParsedFields() { + String errorBody = "{\"errorCode\":\"ORDER_NOT_FOUND\",\"message\":\"Order does not exist\"}"; + HttpClient stubClient = new StubHttpClient(404, errorBody); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + + MontonioApiException exception = assertThrows( + MontonioApiException.class, + () -> client.get("/orders/missing", TestResponse.class) + ); + + assertEquals(404, exception.getStatusCode()); + assertEquals("ORDER_NOT_FOUND", exception.getErrorCode()); + assertEquals("Order does not exist", exception.getErrorMessage()); + } + + @Test + void errorResponseWithNonJsonBodyThrowsApiExceptionWithRawBody() { + HttpClient stubClient = new StubHttpClient(500, "Internal Server Error"); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + + MontonioApiException exception = assertThrows( + MontonioApiException.class, + () -> client.get("/test", TestResponse.class) + ); + + assertEquals(500, exception.getStatusCode()); + assertNull(exception.getErrorCode()); + assertEquals("Internal Server Error", exception.getErrorMessage()); + } + + @Test + void errorResponseWithPartialJsonFieldsThrowsApiExceptionWithAvailableFields() { + String errorBody = "{\"message\":\"Something went wrong\"}"; + HttpClient stubClient = new StubHttpClient(400, errorBody); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + + MontonioApiException exception = assertThrows( + MontonioApiException.class, + () -> client.get("/test", TestResponse.class) + ); + + assertEquals(400, exception.getStatusCode()); + assertNull(exception.getErrorCode()); + assertEquals("Something went wrong", exception.getErrorMessage()); + } + + @Test + void connectionFailureThrowsNetworkException() { + HttpClient stubClient = new IoExceptionHttpClient(new IOException("Connection refused")); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + + MontonioNetworkException exception = assertThrows( + MontonioNetworkException.class, + () -> client.get("/test", TestResponse.class) + ); + + assertTrue(exception.getMessage().contains("Connection refused")); + assertInstanceOf(IOException.class, exception.getCause()); + } + + @Test + void interruptedRequestThrowsNetworkExceptionAndRestoresInterruptFlag() { + HttpClient stubClient = new InterruptedHttpClient(); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + + MontonioNetworkException exception = assertThrows( + MontonioNetworkException.class, + () -> client.get("/test", TestResponse.class) + ); + + assertEquals("Request interrupted", exception.getMessage()); + assertInstanceOf(InterruptedException.class, exception.getCause()); + assertTrue(Thread.currentThread().isInterrupted()); + + // Clear the interrupt flag for test cleanup + Thread.interrupted(); + } + + @Test + void malformedJsonResponseOnSuccessThrowsMontonioException() { + HttpClient stubClient = new StubHttpClient(200, "not json at all"); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + + MontonioException exception = assertThrows( + MontonioException.class, + () -> client.get("/test", TestResponse.class) + ); + + assertTrue(exception.getMessage().contains("Failed to deserialize response body")); + } + + @Test + void errorResponseWithNullJsonFieldsThrowsApiExceptionWithNulls() { + String errorBody = "{\"errorCode\":null,\"message\":null}"; + HttpClient stubClient = new StubHttpClient(422, errorBody); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + + MontonioApiException exception = assertThrows( + MontonioApiException.class, + () -> client.get("/test", TestResponse.class) + ); + + assertEquals(422, exception.getStatusCode()); + assertNull(exception.getErrorCode()); + assertNull(exception.getErrorMessage()); + } + + // --- Helpers --- + + private static String extractRequestBody(HttpRequest request) { + return request.bodyPublisher() + .map(publisher -> { + var subscriber = HttpResponse.BodySubscribers.ofString(java.nio.charset.StandardCharsets.UTF_8); + publisher.subscribe(new java.util.concurrent.Flow.Subscriber<>() { + @Override public void onSubscribe(java.util.concurrent.Flow.Subscription subscription) { + subscriber.onSubscribe(subscription); + } + @Override public void onNext(java.nio.ByteBuffer item) { subscriber.onNext(List.of(item)); } + @Override public void onError(Throwable throwable) { subscriber.onError(throwable); } + @Override public void onComplete() { subscriber.onComplete(); } + }); + return subscriber.getBody().toCompletableFuture().join(); + }) + .orElse(""); + } + + // --- Test DTOs --- + + static class TestResponse { + public String name; + public int value; + } + + static class TestRequest { + public String data; + + TestRequest(String data) { + this.data = data; + } + } + + // --- Stub HttpClient implementations --- + + private static class StubHttpClient extends HttpClient { + + private final int statusCode; + private final String responseBody; + HttpRequest capturedRequest; + + StubHttpClient(int statusCode, String responseBody) { + this.statusCode = statusCode; + this.responseBody = responseBody; + } + + @Override + @SuppressWarnings("unchecked") + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) + throws IOException, InterruptedException { + this.capturedRequest = request; + return (HttpResponse) new StubHttpResponse(statusCode, responseBody, request); + } + + @Override public Optional cookieHandler() { return Optional.empty(); } + @Override public Optional connectTimeout() { return Optional.empty(); } + @Override public Redirect followRedirects() { return Redirect.NEVER; } + @Override public Optional proxy() { return Optional.empty(); } + @Override public javax.net.ssl.SSLContext sslContext() { return null; } + @Override public javax.net.ssl.SSLParameters sslParameters() { return null; } + @Override public Optional authenticator() { return Optional.empty(); } + @Override public Version version() { return Version.HTTP_2; } + @Override public Optional executor() { return Optional.empty(); } + + @Override + public java.util.concurrent.CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { + throw new UnsupportedOperationException(); + } + + @Override + public java.util.concurrent.CompletableFuture> sendAsync( + HttpRequest request, HttpResponse.BodyHandler responseBodyHandler, + HttpResponse.PushPromiseHandler pushPromiseHandler) { + throw new UnsupportedOperationException(); + } + } + + private static class IoExceptionHttpClient extends StubHttpClient { + + private final IOException exception; + + IoExceptionHttpClient(IOException exception) { + super(0, ""); + this.exception = exception; + } + + @Override + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) + throws IOException { + throw exception; + } + } + + private static class InterruptedHttpClient extends StubHttpClient { + + InterruptedHttpClient() { + super(0, ""); + } + + @Override + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) + throws InterruptedException { + throw new InterruptedException("interrupted"); + } + } + + private static class StubHttpResponse implements HttpResponse { + + private final int statusCode; + private final String body; + private final HttpRequest request; + + StubHttpResponse(int statusCode, String body, HttpRequest request) { + this.statusCode = statusCode; + this.body = body; + this.request = request; + } + + @Override public int statusCode() { return statusCode; } + @Override public String body() { return body; } + @Override public HttpRequest request() { return request; } + @Override public Optional> previousResponse() { return Optional.empty(); } + @Override public HttpHeaders headers() { return HttpHeaders.of(Map.of(), (a, b) -> true); } + @Override public URI uri() { return request.uri(); } + @Override public HttpClient.Version version() { return HttpClient.Version.HTTP_2; } + @Override public Optional sslSession() { return Optional.empty(); } + } +}