diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a4bb2e..72cc98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Project scaffold per ADRs 001–006: Gradle Kotlin DSL build, JDK 17 toolchain, `integrationTest` source set, Spotless + JaCoCo, Vanniktech Maven Publish. -- `MarketDataClient` skeleton with builder, default base URL - (`https://api.marketdata.app`), default API version (`v1`), 99 s request / - 2 s connect timeouts, HTTP/2, demo mode, `validateOnStartup` toggle, and a - 50-permit concurrency semaphore (wiring lands with the request layer). -- Configuration cascade: explicit builder values → `MARKETDATA_*` environment - variables → `.env` file in CWD → built-in defaults. +- `MarketDataClient` skeleton with two public constructors — a no-arg one + for production (everything resolved from the cascade) and a 4-arg one + (`apiKey`, `baseUrl`, `apiVersion`, `validateOnStartup`) for tests and + short-lived runtimes. Default base URL (`https://api.marketdata.app`), + default API version (`v1`), 99 s request / 2 s connect timeouts, HTTP/2, + demo mode, `validateOnStartup` toggle, and a 50-permit concurrency + semaphore (wiring lands with the request layer). +- Configuration cascade: explicit constructor parameters → `MARKETDATA_*` + environment variables → `.env` file in CWD → built-in defaults. - Sealed `MarketDataException` hierarchy with the seven canonical subtypes (`AuthenticationError`, `BadRequestError`, `NotFoundError`, `RateLimitError`, `ServerError`, `NetworkError`, `ParseError`), each carrying support context diff --git a/CLAUDE.md b/CLAUDE.md index 39e7e02..970bc1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,9 +60,9 @@ If you find yourself reaching for Lombok, AutoValue, or an abstract-base excepti The Java SDK must also satisfy the canonical, cross-language [SDK Requirements](https://www.marketdata.app/docs/sdk/sdk-requirements/) (referenced from inside the ADRs as `../sdk-requirements.md`). The current scaffold applies the **foundational** rules from that doc; per-endpoint and per-request rules land alongside the request layer. Specifically: **Already wired in:** -- §1.1 client object — `MarketDataClient` builder, default base URL `https://api.marketdata.app`, default API version `v1`, single shared `HttpClient`, `User-Agent: marketdata-sdk-java/{version}` (version auto-detected from JAR manifest), `close()` for resource release, `getRateLimits()` accessor. -- §4 configuration cascade — `Configuration.resolve(...)` does explicit → `MARKETDATA_*` env var → `.env` in CWD → default. Env var names live in `internal.EnvVars`. -- §5 demo mode + `validateOnStartup` toggle on the builder; token redaction via `internal.Tokens.redact` (matches the spec example `***…***YKT0`). +- §1.1 client object — `MarketDataClient` with two public constructors: a no-arg one for production (everything from the cascade) and a 4-arg `(apiKey, baseUrl, apiVersion, validateOnStartup)` for tests and short-lived runtimes. All fields `final` (immutable). Default base URL `https://api.marketdata.app`, default API version `v1`, single shared `HttpClient`, `User-Agent: marketdata-sdk-java/{version}` (version auto-detected from JAR manifest), `close()` for resource release, `getRateLimits()` accessor. +- §4 configuration cascade — `Configuration.resolve(...)` does explicit → `MARKETDATA_*` env var → `.env` in CWD → default. Env var names live in `internal.EnvVars`. The 4-arg constructor's parameters feed step 1; the no-arg constructor skips it and starts at step 2. +- §5 demo mode + `validateOnStartup` parameter on the 4-arg constructor (defaults to `true` via the no-arg constructor); token redaction via `internal.Tokens.redact` (matches the spec example `***…***YKT0`). - §6 sealed `MarketDataException` hierarchy with the 7 canonical subtypes and full support context (`requestId`, `requestUrl`, `statusCode`, `timestamp`, `exceptionType`) + `getSupportInfo()`. - §10 timeouts: `REQUEST_TIMEOUT = 99s` and `CONNECT_TIMEOUT = 2s` exposed as constants on `MarketDataClient`. Connect timeout is wired into the `HttpClient`; the per-request 99 s timeout is a constant ready to be applied to `HttpRequest.Builder#timeout` when the request layer lands. - §12 concurrency: `Semaphore(50)` field on `MarketDataClient` (wiring of acquire/release lands with the request layer). diff --git a/README.md b/README.md index a77e2db..0c286b1 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,13 @@ Java SDK for the [Market Data API](https://www.marketdata.app/). **Pre-release scaffold** — endpoints are not yet implemented; this iteration sets up the build, package layout, configuration cascade, exception taxonomy, and -Kotlin-interop foundations from the [ADRs](docs/adr/) and the canonical -[SDK Requirements](https://www.marketdata.app/docs/sdk/sdk-requirements/). +Kotlin-interop foundations. ## Requirements -- **JDK 17 or newer** (ADR-002). The published artifact is compiled with +- **JDK 17 or newer**. The published artifact is compiled with `javac --release 17`. Tests run on JDK 17, 21, and 25. -- **Jackson 2.18+** on the runtime classpath (ADR-005). Pulled transitively; +- **Jackson 2.18+** on the runtime classpath. Pulled transitively; consumers may align to a newer 2.x. ## Install (planned) @@ -27,12 +26,12 @@ Coordinates are placeholders until the first publication to Maven Central. ## Quick start The SDK reads `MARKETDATA_TOKEN` from the environment by default, so the -common path is two lines (per SDK requirements §"Easy Default Requests"): +common path is two lines: ### Java ```java -try (var client = MarketDataClient.builder().build()) { +try (var client = new MarketDataClient()) { // endpoint methods land in subsequent iterations } ``` @@ -40,17 +39,18 @@ try (var client = MarketDataClient.builder().build()) { ### Kotlin ```kotlin -MarketDataClient.builder().build().use { client -> +MarketDataClient().use { client -> // endpoint methods land in subsequent iterations } ``` ## Configuration -Values are resolved through this cascade (highest priority first), per -SDK requirements §4: +Values are resolved through this cascade (highest priority first): -1. Explicit builder methods — `apiKey(...)`, `baseUrl(...)`, `apiVersion(...)` +1. Explicit constructor parameters — `apiKey`, `baseUrl`, `apiVersion` + (passed to `new MarketDataClient(apiKey, baseUrl, apiVersion, validateOnStartup)`; + the no-arg `new MarketDataClient()` skips this step and starts at #2) 2. Environment variables (table below) 3. `.env` file in the current working directory 4. Built-in defaults @@ -97,10 +97,10 @@ try { } ``` -The seven permitted subtypes — `AuthenticationError`, `BadRequestError`, -`NotFoundError`, `RateLimitError`, `ServerError`, `NetworkError`, -`ParseError` — match SDK requirements §6.1. The hierarchy is sealed so -`switch` over the subtypes is compile-time exhaustive (ADR-002). +The seven permitted subtypes are `AuthenticationError`, `BadRequestError`, +`NotFoundError`, `RateLimitError`, `ServerError`, `NetworkError`, and +`ParseError`. The hierarchy is sealed so `switch` over the subtypes is +compile-time exhaustive. ## Build @@ -118,7 +118,7 @@ install — the wrapper downloads the right Gradle version on first run. ./gradlew spotlessApply # auto-format ./gradlew jacocoTestReport # coverage report → build/reports/jacoco/ -# Integration tests hit the live API — gated by env var (ADR-003 §13). +# Integration tests hit the live API — gated by env var. MARKETDATA_RUN_INTEGRATION_TESTS=true ./gradlew integrationTest ``` @@ -132,25 +132,7 @@ com.marketdata.sdk.internal # Tokens, EnvVars, Configuration, Version (do not Every public package is `@NullMarked` (JSpecify): non-null is the default; nullable items are tagged explicitly. This is what makes Kotlin's null -safety work against this Java API (ADR-001 §2.1). - -## Architectural decisions - -All foundational decisions are captured as ADRs and are **Accepted**: - -| ADR | Decision | -|-----|----------| -| [001](docs/adr/ADR-001-java-only-vs-multi-language-sdk.md) | Java only; Kotlin consumers via interop, not a Kotlin artifact | -| [002](docs/adr/ADR-002-minimum-jdk-version.md) | Minimum JDK 17; CI matrix `{17, 21, 25}` | -| [003](docs/adr/ADR-003-build-tool.md) | Gradle (Kotlin DSL) + version catalog | -| [004](docs/adr/ADR-004-http-client.md) | `java.net.http.HttpClient` exclusively | -| [005](docs/adr/ADR-005-json-library.md) | Jackson (`jackson-databind`) | -| [006](docs/adr/ADR-006-async-api-surface.md) | Sync + async parity, async-first internally | - -Java-specific requirements derived from the ADRs live in -[`docs/java-sdk-requirements.md`](docs/java-sdk-requirements.md). The -canonical, cross-language requirements are at -[marketdata.app/docs/sdk/sdk-requirements](https://www.marketdata.app/docs/sdk/sdk-requirements/). +safety work against this Java API. ## License diff --git a/docs/java-sdk-requirements.md b/docs/java-sdk-requirements.md index e359c65..ca24932 100644 --- a/docs/java-sdk-requirements.md +++ b/docs/java-sdk-requirements.md @@ -118,9 +118,12 @@ The README and per-method docs must include at least one Kotlin usage example alongside the Java example for the quick-start path: ```kotlin -val client = MarketDataClient.builder() - .apiKey("KEY") - .build() +val client = MarketDataClient( + apiKey = "KEY", + baseUrl = null, + apiVersion = null, + validateOnStartup = true, +) val quote = client.stocks().quote("AAPL") println(quote) diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index b4ab403..c2410d5 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -16,13 +16,22 @@ * Entry point to the Market Data Java SDK. * *
One {@code MarketDataClient} per application. Holds a single shared {@link HttpClient} - * (HTTP/2, 2 s connect timeout) for connection pooling (ADR-004) and a 50-permit semaphore that - * gates the global concurrency pool required by SDK requirements §12. + * (HTTP/2, 2 s connect timeout) for connection pooling and a 50-permit semaphore that gates the + * global concurrency pool required by SDK requirements §12. * - *
Construction follows the configuration cascade in §4: explicit builder values → {@code - * MARKETDATA_*} environment variables → values in a {@code .env} file in the working directory → - * built-in defaults. Pass no token to enter demo mode (authenticated endpoints will fail; - * the {@code Authorization} header is omitted). + *
Two constructors: + * + *
Instances are immutable: every field is {@code final} and assigned in the constructor. */ public final class MarketDataClient implements AutoCloseable { @@ -48,18 +57,46 @@ public final class MarketDataClient implements AutoCloseable { private final boolean demoMode; private final boolean validateOnStartup; - private MarketDataClient(Builder builder) { + /** + * Production constructor. Resolves all settings from the configuration cascade in SDK + * requirements §4 (env var → {@code .env} → built-in default) and enables startup validation. + * + *
Equivalent to {@link #MarketDataClient(String, String, String, boolean) new + * MarketDataClient(null, null, null, true)}. + */ + public MarketDataClient() { + this(null, null, null, true); + } + + /** + * Explicit-control constructor for tests and short-lived runtimes. Each of {@code apiKey}, {@code + * baseUrl}, and {@code apiVersion} may be {@code null} to defer to the cascade in §4 for that + * single value. + * + * @param apiKey explicit API token, or {@code null} to resolve from {@code MARKETDATA_TOKEN} → + * {@code .env} → demo mode + * @param baseUrl override the API base URL, or {@code null} to resolve to {@link + * Configuration#DEFAULT_BASE_URL} + * @param apiVersion override the API version segment, or {@code null} to resolve to {@link + * Configuration#DEFAULT_API_VERSION} + * @param validateOnStartup whether to validate the token on construction by calling {@code + * /user/} (SDK requirements §5). Pass {@code false} for short-lived runtimes where the + * startup hit is undesirable. + */ + public MarketDataClient( + @Nullable String apiKey, + @Nullable String baseUrl, + @Nullable String apiVersion, + boolean validateOnStartup) { Configuration config = Configuration.loadFromProcess(); - this.token = config.resolve(builder.apiKey, EnvVars.TOKEN); + this.token = config.resolve(apiKey, EnvVars.TOKEN); this.baseUrl = trimTrailingSlash( - config.resolveOrDefault( - builder.baseUrl, EnvVars.BASE_URL, Configuration.DEFAULT_BASE_URL)); + config.resolveOrDefault(baseUrl, EnvVars.BASE_URL, Configuration.DEFAULT_BASE_URL)); this.apiVersion = - config.resolveOrDefault( - builder.apiVersion, EnvVars.API_VERSION, Configuration.DEFAULT_API_VERSION); + config.resolveOrDefault(apiVersion, EnvVars.API_VERSION, Configuration.DEFAULT_API_VERSION); this.demoMode = this.token == null; - this.validateOnStartup = builder.validateOnStartup; + this.validateOnStartup = validateOnStartup; this.userAgent = "marketdata-sdk-java/" + Version.current(); this.httpClient = @@ -73,23 +110,19 @@ private MarketDataClient(Builder builder) { LOG.log( Level.INFO, "Initialized Market Data SDK {0} (baseUrl={1}, apiVersion={2}, demoMode={3})", - new Object[] {Version.current(), baseUrl, apiVersion, demoMode}); - if (demoMode) { + new Object[] {Version.current(), this.baseUrl, this.apiVersion, this.demoMode}); + if (this.demoMode) { LOG.warning( "No API token provided — running in demo mode. Authenticated endpoints will" + " fail; rate-limit initialization is skipped."); } else if (LOG.isLoggable(Level.FINE)) { - LOG.log(Level.FINE, "Token: {0}", Tokens.redact(token)); + LOG.log(Level.FINE, "Token: {0}", Tokens.redact(this.token)); } // SDK requirements §5: validate on startup by default. The actual // /user/ call lands with the request layer; this flag is the seam. } - public static Builder builder() { - return new Builder(); - } - public String getBaseUrl() { return baseUrl; } @@ -118,53 +151,12 @@ public boolean isValidateOnStartup() { @Override public void close() { // java.net.http.HttpClient gained explicit close() in JDK 21. - // While the minimum target is JDK 17 (ADR-002), this method is a - // no-op: the JVM releases the executor and connection pool on - // process exit. Revisit if/when the minimum bumps to 21+. + // While the minimum target is JDK 17, this method is a no-op: + // the JVM releases the executor and connection pool on process + // exit. Revisit if/when the minimum bumps to 21+. } private static String trimTrailingSlash(String url) { return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; } - - public static final class Builder { - private @Nullable String apiKey; - private @Nullable String baseUrl; - private @Nullable String apiVersion; - private boolean validateOnStartup = true; - - private Builder() {} - - /** Override the API token; otherwise resolved from {@code MARKETDATA_TOKEN} or {@code .env}. */ - public Builder apiKey(String apiKey) { - this.apiKey = apiKey; - return this; - } - - /** Override the base URL (default {@value Configuration#DEFAULT_BASE_URL}). */ - public Builder baseUrl(String baseUrl) { - this.baseUrl = baseUrl; - return this; - } - - /** Override the API version (default {@value Configuration#DEFAULT_API_VERSION}). */ - public Builder apiVersion(String apiVersion) { - this.apiVersion = apiVersion; - return this; - } - - /** - * Whether to validate the token at construction by calling {@code /user/} (SDK requirements - * §5). Defaults to {@code true}. Disable for short-lived runtimes where the startup hit is - * undesirable. - */ - public Builder validateOnStartup(boolean validateOnStartup) { - this.validateOnStartup = validateOnStartup; - return this; - } - - public MarketDataClient build() { - return new MarketDataClient(this); - } - } } diff --git a/src/main/java/com/marketdata/sdk/exception/MarketDataException.java b/src/main/java/com/marketdata/sdk/exception/MarketDataException.java index f5b427b..bb16a91 100644 --- a/src/main/java/com/marketdata/sdk/exception/MarketDataException.java +++ b/src/main/java/com/marketdata/sdk/exception/MarketDataException.java @@ -8,9 +8,9 @@ /** * Root of the SDK exception hierarchy. * - *
Sealed (ADR-002) so consumer {@code switch} statements over its subtypes are compile-time - * exhaustive. Every instance carries the support context fields required by SDK requirements §6.2 - * and exposes a {@link #getSupportInfo()} string per §6.3. + *
Sealed so consumer {@code switch} statements over its subtypes are compile-time exhaustive. + * Every instance carries the support context fields required by SDK requirements §6.2 and exposes a + * {@link #getSupportInfo()} string per §6.3. * *
Subtypes use {@link ErrorContext#empty()} for client-side validation errors that occur before * any HTTP request is dispatched. diff --git a/src/main/java/com/marketdata/sdk/exception/package-info.java b/src/main/java/com/marketdata/sdk/exception/package-info.java index 3fb0727..456d22e 100644 --- a/src/main/java/com/marketdata/sdk/exception/package-info.java +++ b/src/main/java/com/marketdata/sdk/exception/package-info.java @@ -1,9 +1,9 @@ /** * Sealed exception hierarchy thrown by the SDK. * - *
The {@link com.marketdata.sdk.exception.MarketDataException} root is sealed (ADR-002) so - * consumer {@code switch} statements over the known subtypes are compiler-checked for - * exhaustiveness. Adding a new subtype is a breaking change. + *
The {@link com.marketdata.sdk.exception.MarketDataException} root is sealed so consumer {@code + * switch} statements over the known subtypes are compiler-checked for exhaustiveness. Adding a new + * subtype is a breaking change. */ @NullMarked package com.marketdata.sdk.exception; diff --git a/src/main/java/com/marketdata/sdk/package-info.java b/src/main/java/com/marketdata/sdk/package-info.java index c8dc2bb..b23a077 100644 --- a/src/main/java/com/marketdata/sdk/package-info.java +++ b/src/main/java/com/marketdata/sdk/package-info.java @@ -1,8 +1,8 @@ /** * Market Data Java SDK — public API entry point. * - *
Per ADR-001 §2.1, the entire public API is {@code @NullMarked}: every type, parameter, return, - * and field is non-null by default. Mark nullable items explicitly with {@link + *
The entire public API is {@code @NullMarked}: every type, parameter, return, and field is + * non-null by default. Mark nullable items explicitly with {@link * org.jspecify.annotations.Nullable}. */ @NullMarked diff --git a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java index 8e9cfc8..24f0ddf 100644 --- a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java +++ b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java @@ -9,7 +9,7 @@ class MarketDataClientTest { @Test void buildsWithExplicitToken() { - try (var client = MarketDataClient.builder().apiKey("test-key").build()) { + try (var client = new MarketDataClient("test-key", null, null, true)) { assertThat(client.isDemoMode()).isFalse(); assertThat(client.getBaseUrl()).isEqualTo(Configuration.DEFAULT_BASE_URL); assertThat(client.getApiVersion()).isEqualTo(Configuration.DEFAULT_API_VERSION); @@ -18,26 +18,41 @@ void buildsWithExplicitToken() { @Test void demoModeWhenNoTokenAvailable() { - // No apiKey set on the builder. Demo mode iff the env/dotenv + // No apiKey passed to the constructor. Demo mode iff the env/dotenv // cascade also yields nothing — true on any CI environment that // doesn't export MARKETDATA_TOKEN. This assertion is conditional // so the test stays valid in both cases. - try (var client = MarketDataClient.builder().build()) { + try (var client = new MarketDataClient()) { String envToken = System.getenv("MARKETDATA_TOKEN"); boolean expectDemo = envToken == null || envToken.isBlank(); assertThat(client.isDemoMode()).isEqualTo(expectDemo); } } + @Test + void noArgConstructorAppliesProductionDefaults() { + // The no-arg constructor must be equivalent to `new MarketDataClient(null, null, null, + // true)` — production path with everything resolved from the cascade and startup + // validation enabled. validateOnStartup and the userAgent format are env-independent, + // so we assert them unconditionally; baseUrl/apiVersion fall back to the documented + // defaults only when the cascade has no override, so we gate those assertions on the + // env vars being unset (mirrors the demo-mode test above). + try (var client = new MarketDataClient()) { + assertThat(client.isValidateOnStartup()).isTrue(); + assertThat(client.getUserAgent()).startsWith("marketdata-sdk-java/"); + + if (System.getenv("MARKETDATA_BASE_URL") == null) { + assertThat(client.getBaseUrl()).isEqualTo(Configuration.DEFAULT_BASE_URL); + } + if (System.getenv("MARKETDATA_API_VERSION") == null) { + assertThat(client.getApiVersion()).isEqualTo(Configuration.DEFAULT_API_VERSION); + } + } + } + @Test void overridesAreHonored() { - try (var client = - MarketDataClient.builder() - .apiKey("KEY") - .baseUrl("https://example.test/") - .apiVersion("v2") - .validateOnStartup(false) - .build()) { + try (var client = new MarketDataClient("KEY", "https://example.test/", "v2", false)) { assertThat(client.getBaseUrl()).isEqualTo("https://example.test"); // trailing slash trimmed assertThat(client.getApiVersion()).isEqualTo("v2"); assertThat(client.isValidateOnStartup()).isFalse(); @@ -46,14 +61,14 @@ void overridesAreHonored() { @Test void userAgentMatchesSpec() { - try (var client = MarketDataClient.builder().apiKey("KEY").build()) { + try (var client = new MarketDataClient("KEY", null, null, true)) { assertThat(client.getUserAgent()).startsWith("marketdata-sdk-java/"); } } @Test void rateLimitsStartUnpopulated() { - try (var client = MarketDataClient.builder().apiKey("KEY").build()) { + try (var client = new MarketDataClient("KEY", null, null, true)) { assertThat(client.getRateLimits()).isNull(); } }