diff --git a/docs/plans/2026-04-10-sdk-configuration-design.md b/docs/plans/2026-04-10-sdk-configuration-design.md new file mode 100644 index 0000000..aaa41a9 --- /dev/null +++ b/docs/plans/2026-04-10-sdk-configuration-design.md @@ -0,0 +1,158 @@ +# SDK Configuration Design + +**Issue:** #10 — Core configuration and multi-merchant credential support +**Date:** 2026-04-10 +**Status:** Proposed + +## Overview + +Expand `MontonioSdkConfiguration` from a minimal base-URL holder into a complete, immutable configuration object with credentials, timeouts, and token settings. Built via Lombok `@Builder` with validation at construction time. + +Multi-merchant support is intentionally deferred — consumers who need multiple merchants simply create multiple configuration instances. + +## Design Decisions + +| Decision | Choice | Rationale | +|----------------------------|----------------------------------------------|---------------------------------------------------------------------------------| +| Multi-merchant approach | Multiple instances, no built-in registry | YAGNI; consumers manage their own instances | +| Environment selection | Base URL string + constants | Simple, flexible; enum wrapper adds little value for a rarely changed URL | +| Timeout granularity | Connect + request (two fields) | Matches `java.net.http.HttpClient` natively; no separate write timeout needed | +| Timeout type | `java.time.Duration` | Idiomatic, self-documenting units, native HttpClient compatibility | +| Construction | Lombok `@Builder` with custom `build()` | Fluent API, immutable result, validation at construction time | +| Mutability | Immutable (`@Getter` only, `final` fields) | Safer for concurrent use; no reason to mutate after construction | +| Defaults | Sensible defaults for all optional fields | Works out of the box with just credentials | +| Validation | Fail-fast in `build()` via `MontonioValidationException` | Catches missing credentials early; reuses existing exception type | + +## Configuration Fields + +| Field | Type | Default | Required | +|----------------------|------------|----------------------------------|----------| +| `accessKey` | `String` | none | yes | +| `secretKey` | `String` | none | yes | +| `baseUrl` | `String` | `SANDBOX_BASE_URL` | no | +| `connectTimeout` | `Duration` | 10 seconds | no | +| `requestTimeout` | `Duration` | 30 seconds | no | +| `tokenExpirationTime`| `Duration` | 5 minutes | no | + +## Constants + +```java +public static final String SANDBOX_BASE_URL = "https://sandbox-stargate.montonio.com/api"; +public static final String PRODUCTION_BASE_URL = "https://stargate.montonio.com/api"; +``` + +## Class Specification + +```java +package ee.bitweb.montonio.sdk; + +import ee.bitweb.montonio.sdk.exception.MontonioValidationException; +import lombok.Builder; +import lombok.Getter; + +import java.time.Duration; + +@Getter +@Builder +public class MontonioSdkConfiguration { + + public static final String SANDBOX_BASE_URL = "https://sandbox-stargate.montonio.com/api"; + public static final String PRODUCTION_BASE_URL = "https://stargate.montonio.com/api"; + + private final String accessKey; + private final String secretKey; + + @Builder.Default + private final String baseUrl = SANDBOX_BASE_URL; + + @Builder.Default + private final Duration connectTimeout = Duration.ofSeconds(10); + + @Builder.Default + private final Duration requestTimeout = Duration.ofSeconds(30); + + @Builder.Default + private final Duration tokenExpirationTime = Duration.ofMinutes(5); + + MontonioSdkConfiguration( + String accessKey, + String secretKey, + String baseUrl, + Duration connectTimeout, + Duration requestTimeout, + Duration tokenExpirationTime + ) { + if (accessKey == null || accessKey.isBlank()) { + throw new MontonioValidationException("accessKey", "must not be null or blank"); + } + if (secretKey == null || secretKey.isBlank()) { + throw new MontonioValidationException("secretKey", "must not be null or blank"); + } + this.accessKey = accessKey; + this.secretKey = secretKey; + this.baseUrl = baseUrl; + this.connectTimeout = connectTimeout; + this.requestTimeout = requestTimeout; + this.tokenExpirationTime = tokenExpirationTime; + } +} +``` + +**Note:** `@Builder.Default` handles defaults in the generated `build()` method. Validation lives in the package-private all-args constructor, which Lombok's builder calls — no custom builder class needed. + +## Usage Examples + +### Minimal (sandbox, defaults) + +```java +MontonioSdkConfiguration config = MontonioSdkConfiguration.builder() + .accessKey("your-access-key") + .secretKey("your-secret-key") + .build(); +``` + +### Production with custom timeouts + +```java +MontonioSdkConfiguration config = MontonioSdkConfiguration.builder() + .accessKey("your-access-key") + .secretKey("your-secret-key") + .baseUrl(MontonioSdkConfiguration.PRODUCTION_BASE_URL) + .connectTimeout(Duration.ofSeconds(5)) + .requestTimeout(Duration.ofSeconds(15)) + .tokenExpirationTime(Duration.ofMinutes(10)) + .build(); +``` + +### Multi-merchant (consumer-managed) + +```java +MontonioSdkConfiguration eeConfig = MontonioSdkConfiguration.builder() + .accessKey("ee-access-key") + .secretKey("ee-secret-key") + .build(); + +MontonioSdkConfiguration lvConfig = MontonioSdkConfiguration.builder() + .accessKey("lv-access-key") + .secretKey("lv-secret-key") + .build(); +``` + +## Testing Strategy + +**Test class:** `MontonioSdkConfigurationTest` (replaces existing minimal version) + +**Test cases:** + +| Test | What it verifies | +|------|------------------| +| Builder with required fields only | All defaults applied: sandbox URL, 10s connect, 30s request, 5min token | +| Builder with all fields overridden | Each custom value returned by getter | +| Production URL constant | `PRODUCTION_BASE_URL` equals `https://stargate.montonio.com/api` | +| Sandbox URL constant | `SANDBOX_BASE_URL` equals `https://sandbox-stargate.montonio.com/api` | +| Missing accessKey (null) | Throws `MontonioValidationException` with field `"accessKey"` | +| Missing secretKey (null) | Throws `MontonioValidationException` with field `"secretKey"` | +| Blank accessKey | Throws `MontonioValidationException` with field `"accessKey"` | +| Blank secretKey | Throws `MontonioValidationException` with field `"secretKey"` | + +No integration tests needed. Target: 100% line coverage. diff --git a/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java b/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java index b6b2f93..1d28c60 100644 --- a/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java +++ b/src/main/java/ee/bitweb/montonio/sdk/MontonioSdkConfiguration.java @@ -1,13 +1,64 @@ package ee.bitweb.montonio.sdk; +import ee.bitweb.montonio.sdk.exception.MontonioValidationException; +import lombok.Builder; import lombok.Getter; -import lombok.Setter; + +import java.time.Duration; @Getter -@Setter +@Builder public class MontonioSdkConfiguration { public static final String SANDBOX_BASE_URL = "https://sandbox-stargate.montonio.com/api"; + public static final String PRODUCTION_BASE_URL = "https://stargate.montonio.com/api"; + + private final String accessKey; + private final String secretKey; + + @Builder.Default + private final String baseUrl = SANDBOX_BASE_URL; + + @Builder.Default + private final Duration connectTimeout = Duration.ofSeconds(10); + + @Builder.Default + private final Duration requestTimeout = Duration.ofSeconds(30); + + @Builder.Default + private final Duration tokenExpirationTime = Duration.ofMinutes(5); - private String baseUrl = SANDBOX_BASE_URL; + MontonioSdkConfiguration( + String accessKey, + String secretKey, + String baseUrl, + Duration connectTimeout, + Duration requestTimeout, + Duration tokenExpirationTime + ) { + if (accessKey == null || accessKey.isBlank()) { + throw new MontonioValidationException("accessKey", "must not be null or blank"); + } + if (secretKey == null || secretKey.isBlank()) { + throw new MontonioValidationException("secretKey", "must not be null or blank"); + } + if (baseUrl == null || baseUrl.isBlank()) { + throw new MontonioValidationException("baseUrl", "must not be null or blank"); + } + if (connectTimeout == null || connectTimeout.isNegative()) { + throw new MontonioValidationException("connectTimeout", "must not be null or negative"); + } + if (requestTimeout == null || requestTimeout.isNegative()) { + throw new MontonioValidationException("requestTimeout", "must not be null or negative"); + } + if (tokenExpirationTime == null || tokenExpirationTime.isNegative()) { + throw new MontonioValidationException("tokenExpirationTime", "must not be null or negative"); + } + this.accessKey = accessKey; + this.secretKey = secretKey; + this.baseUrl = baseUrl; + this.connectTimeout = connectTimeout; + this.requestTimeout = requestTimeout; + this.tokenExpirationTime = tokenExpirationTime; + } } diff --git a/src/test/java/ee/bitweb/montonio/sdk/MontonioSdkConfigurationTest.java b/src/test/java/ee/bitweb/montonio/sdk/MontonioSdkConfigurationTest.java index 9f65d37..e8e5d7e 100644 --- a/src/test/java/ee/bitweb/montonio/sdk/MontonioSdkConfigurationTest.java +++ b/src/test/java/ee/bitweb/montonio/sdk/MontonioSdkConfigurationTest.java @@ -1,15 +1,204 @@ package ee.bitweb.montonio.sdk; +import ee.bitweb.montonio.sdk.exception.MontonioValidationException; import org.junit.jupiter.api.Test; +import java.time.Duration; + import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; class MontonioSdkConfigurationTest { @Test - void defaultBaseUrlIsSandbox() { - MontonioSdkConfiguration configuration = new MontonioSdkConfiguration(); + void buildWithRequiredFieldsOnlyAppliesDefaults() { + MontonioSdkConfiguration config = MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .build(); + + assertEquals("test-access-key", config.getAccessKey()); + assertEquals("test-secret-key", config.getSecretKey()); + assertEquals(MontonioSdkConfiguration.SANDBOX_BASE_URL, config.getBaseUrl()); + assertEquals(Duration.ofSeconds(10), config.getConnectTimeout()); + assertEquals(Duration.ofSeconds(30), config.getRequestTimeout()); + assertEquals(Duration.ofMinutes(5), config.getTokenExpirationTime()); + } + + @Test + void buildWithAllFieldsOverridden() { + MontonioSdkConfiguration config = MontonioSdkConfiguration.builder() + .accessKey("custom-access-key") + .secretKey("custom-secret-key") + .baseUrl(MontonioSdkConfiguration.PRODUCTION_BASE_URL) + .connectTimeout(Duration.ofSeconds(5)) + .requestTimeout(Duration.ofSeconds(15)) + .tokenExpirationTime(Duration.ofMinutes(10)) + .build(); + + assertEquals("custom-access-key", config.getAccessKey()); + assertEquals("custom-secret-key", config.getSecretKey()); + assertEquals(MontonioSdkConfiguration.PRODUCTION_BASE_URL, config.getBaseUrl()); + assertEquals(Duration.ofSeconds(5), config.getConnectTimeout()); + assertEquals(Duration.ofSeconds(15), config.getRequestTimeout()); + assertEquals(Duration.ofMinutes(10), config.getTokenExpirationTime()); + } + + @Test + void sandboxBaseUrlConstant() { + assertEquals("https://sandbox-stargate.montonio.com/api", MontonioSdkConfiguration.SANDBOX_BASE_URL); + } + + @Test + void productionBaseUrlConstant() { + assertEquals("https://stargate.montonio.com/api", MontonioSdkConfiguration.PRODUCTION_BASE_URL); + } + + @Test + void buildWithNullAccessKeyThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .secretKey("test-secret-key") + .build() + ); + + assertEquals("accessKey", exception.getField()); + } + + @Test + void buildWithNullSecretKeyThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .build() + ); + + assertEquals("secretKey", exception.getField()); + } + + @Test + void buildWithBlankAccessKeyThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey(" ") + .secretKey("test-secret-key") + .build() + ); + + assertEquals("accessKey", exception.getField()); + } + + @Test + void buildWithBlankSecretKeyThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey(" ") + .build() + ); + + assertEquals("secretKey", exception.getField()); + } + + @Test + void buildWithNullBaseUrlThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .baseUrl(null) + .build() + ); + + assertEquals("baseUrl", exception.getField()); + } + + @Test + void buildWithNullConnectTimeoutThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .connectTimeout(null) + .build() + ); + + assertEquals("connectTimeout", exception.getField()); + } + + @Test + void buildWithNullRequestTimeoutThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .requestTimeout(null) + .build() + ); + + assertEquals("requestTimeout", exception.getField()); + } + + @Test + void buildWithNullTokenExpirationTimeThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .tokenExpirationTime(null) + .build() + ); + + assertEquals("tokenExpirationTime", exception.getField()); + } + + @Test + void buildWithNegativeConnectTimeoutThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .connectTimeout(Duration.ofSeconds(-1)) + .build() + ); + + assertEquals("connectTimeout", exception.getField()); + } + + @Test + void buildWithNegativeRequestTimeoutThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .requestTimeout(Duration.ofSeconds(-1)) + .build() + ); + + assertEquals("requestTimeout", exception.getField()); + } + + @Test + void buildWithNegativeTokenExpirationTimeThrows() { + MontonioValidationException exception = assertThrows( + MontonioValidationException.class, + () -> MontonioSdkConfiguration.builder() + .accessKey("test-access-key") + .secretKey("test-secret-key") + .tokenExpirationTime(Duration.ofSeconds(-1)) + .build() + ); - assertEquals("https://sandbox-stargate.montonio.com/api", configuration.getBaseUrl()); + assertEquals("tokenExpirationTime", exception.getField()); } }