Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
50 changes: 16 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -27,30 +26,31 @@ 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
}
```

### 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
Expand Down Expand Up @@ -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

Expand All @@ -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
```

Expand All @@ -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

Expand Down
9 changes: 6 additions & 3 deletions docs/java-sdk-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
120 changes: 56 additions & 64 deletions src/main/java/com/marketdata/sdk/MarketDataClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,22 @@
* Entry point to the Market Data Java SDK.
*
* <p>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.
*
* <p>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 <em>demo mode</em> (authenticated endpoints will fail;
* the {@code Authorization} header is omitted).
* <p>Two constructors:
*
* <ul>
* <li>{@link #MarketDataClient()} — production path. Resolves everything from the cascade in §4
* ({@code MARKETDATA_*} environment variable → value in a {@code .env} file → built-in
* default). With no token in the cascade, enters <em>demo mode</em> — authenticated endpoints
* will fail and the {@code Authorization} header is omitted.
* <li>{@link #MarketDataClient(String, String, String, boolean)} — explicit-control path for
* tests and short-lived runtimes. Each parameter may still be {@code null} to defer to the
* cascade for that single value.
* </ul>
*
* <p>Instances are immutable: every field is {@code final} and assigned in the constructor.
*/
public final class MarketDataClient implements AutoCloseable {

Expand All @@ -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.
*
* <p>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 =
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
/**
* Root of the SDK exception hierarchy.
*
* <p>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.
* <p>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.
*
* <p>Subtypes use {@link ErrorContext#empty()} for client-side validation errors that occur before
* any HTTP request is dispatched.
Expand Down
6 changes: 3 additions & 3 deletions src/main/java/com/marketdata/sdk/exception/package-info.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* Sealed exception hierarchy thrown by the SDK.
*
* <p>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.
* <p>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;
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/com/marketdata/sdk/package-info.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* Market Data Java SDK — public API entry point.
*
* <p>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
* <p>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
Expand Down
Loading
Loading