Skip to content

Commit 77489b8

Browse files
Merge pull request #8 from MarketDataApp/clean-architecture-restart
Clean-architecture restart: SDK foundation + `utilities` resource
2 parents 6727439 + df3becd commit 77489b8

117 files changed

Lines changed: 12958 additions & 3143 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,16 @@ Thumbs.db
2525
.env
2626
.env.local
2727

28+
# Python (examples/mock-server)
29+
.venv/
30+
__pycache__/
31+
*.pyc
32+
2833
# Logs / coverage
2934
*.log
3035
hs_err_pid*
3136
replay_pid*
37+
38+
39+
.claude/reviews/
40+
.claude/prompts/

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111
- Default retry attempts corrected from 3 to 4 (one initial + three retries) to
1212
match SDK requirements §9.3 ("max 3 retries, yielding 4 total attempts").
13+
- `.env` parser now strips trailing inline `# comment` markers (quote-aware: a
14+
`#` inside single/double quotes or adjacent to value chars stays part of the
15+
value). Previously a line like `MARKETDATA_TOKEN=abc # prod` produced the
16+
literal value `abc # prod`, which passes `validateApiKey` (printable ASCII)
17+
and surfaces later as a confusing `AuthenticationError` far from the .env
18+
source that caused it.
19+
- `RequestHeaders` canonical constructor now rejects a `null` `headers` map
20+
with a clear `NullPointerException` naming the field, replacing the bare
21+
`Map.copyOf(null)` NPE that left consumers hunting for the offending
22+
argument. The wire-format deserializer additionally intercepts a top-level
23+
JSON `null` body via `JsonDeserializer#getNullValue` and surfaces it as a
24+
`ParseError` carrying the endpoint URL, status, and request id — preventing
25+
a malformed `/headers/` response from manifesting as an opaque NPE further
26+
down the call stack.
1327

1428
### Added
1529
- Project scaffold per ADRs 001–007: Gradle Kotlin DSL build, JDK 17 toolchain,

CLAUDE.md

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44

55
## Repository state
66

7-
This repo currently contains **documentation only** — no Java sources, no build scripts. Branch `00_base_setup` is the pre-implementation phase: all foundational technical decisions are being captured as ADRs *before* code lands. There is therefore nothing to build, lint, or test yet. When implementation starts, the build will be Gradle (Kotlin DSL) per ADR-003 — see "Locked-in tech stack" below.
7+
This repo contains a working Java SDK in active development on branch `clean-architecture-restart`. Gradle 9.0 (Kotlin DSL) build per ADR-003; ~48 main + ~28 test source files; full CI matrix per ADR-002. The transport, retry, rate-limit, status-cache, and exception layers are wired; only the `utilities` resource façade is implemented today — stocks/options/funds/markets resources are still to come (see "Deliberately deferred" below).
88

99
Sibling repo: `../api/` is the backend (Python/Django). The Python SDK lives at `../../sdk-py/` (referenced from ADRs). The cross-language `sdk-requirements.md` is referenced as `../sdk-requirements.md` from inside `docs/`; it is canonical but not committed in this repo.
1010

@@ -62,14 +62,17 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements](
6262

6363
**Already wired in:**
6464
- §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.
65-
- §4 configuration cascade — `Configuration.resolve(...)` does explicit → `MARKETDATA_*` env var → `.env` in CWD → default. Env var names live in `EnvVars` (package-private, in the SDK root package). The 4-arg constructor's parameters feed step 1; the no-arg constructor skips it and starts at step 2.
66-
- §5 demo mode + `validateOnStartup` parameter on the 4-arg constructor (defaults to `true` via the no-arg constructor); token redaction via `Tokens.redact` (matches the spec example `***…***YKT0`).
65+
- §4 configuration cascade — `Configuration.resolve(...)` does explicit → `MARKETDATA_*` env var → `.env` in CWD → default. Env var names live in `EnvVars` (package-private, in the SDK root package). `baseUrl` and `apiVersion` are normalized (trailing/leading slashes stripped) and validated (scheme http/https, host present, no query/fragment/user-info on `baseUrl`; `[A-Za-z0-9._-]+` on `apiVersion`) at resolution time, so misconfigured cascade inputs fail at construction with a clear message instead of producing malformed URLs later.
66+
- §5 demo mode + `validateOnStartup` parameter on the 4-arg constructor (defaults to `true` via the no-arg constructor); token redaction via `Tokens.redact` (matches the spec example `***…***YKT0`). `runStartupValidation` fires a single `GET /user/` (unversioned, like `/status/` and `/headers/`) via `utilities.validateAuth()` with `RetryPolicy.noRetry()`, skipping in demo mode; the no-retry policy keeps construction snappy on a slow/down API.
6767
- §6 sealed `MarketDataException` hierarchy with the 7 canonical subtypes and full support context (`requestId`, `requestUrl`, `statusCode`, `timestamp`, `exceptionType`) + `getSupportInfo()`.
68+
- §7 logging — `MarketDataLogging.configure(...)` installs `CanonicalLogFormatter` (the spec's `{timestamp} - {logger_name} - {level} - {message}` shape) on `com.marketdata.sdk` and applies the level from `MARKETDATA_LOGGING_LEVEL`. Detects consumer-pre-configured loggers (handler attached or level set) and backs off entirely so embedding the SDK doesn't clobber existing logging setups.
69+
- §8 rate-limit tracking — `RateLimitHeaders.parse` reads the four `x-api-ratelimit-*` headers per response (all-or-nothing: a partial header set returns null rather than emitting a snapshot with phantom zeros); `HttpTransport.latestRateLimits` keeps the latest snapshot, exposed via `client.getRateLimits()`. §10.3 preflight (`HttpTransport.checkRateLimitPreflight`) fails fast on `remaining=0`, but only while `now < reset` — once the reset window elapses the request goes through so the next response refreshes the snapshot.
70+
- §9 retry/backoff — `RetryPolicy` (4 total attempts = 1 initial + 3 retries, exponential 1s→30s per §9.3) wired into `HttpTransport.executeAsync` via a per-attempt loop using `CompletableFuture.delayedExecutor` (no scheduled threads). Network errors and HTTP 501–599 retry; 500 and 4xx do not. §9.4 `Retry-After`: parsed from response headers via `RetryAfterHeader.parse` (supports both delta-seconds and HTTP-date), attached to `ServerError`, and honored by `RetryPolicy.backoffDelay` as an override of the calculated exponential delay. §9.5 `/status/` cache: `StatusCache` (stale-while-revalidate, 270s refresh / 300s expiry) gates retries on services reported offline; `HttpTransport.cacheAllowsRetry` has a self-referential bypass for `/status/` itself so the cache can never block its own refresh.
6871
- §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 applied via `HttpRequest.Builder#timeout` in `HttpTransport.buildRequest`.
6972
- §12 concurrency: 50-permit `AsyncSemaphore` on `HttpTransport` with acquire/release wired around every dispatch. The custom semaphore replaces `java.util.concurrent.Semaphore` so `executeAsync` never parks the caller's thread on a full pool (ADR-007).
70-
- §9 retry/backoff: `RetryPolicy` (4 total attempts = 1 initial + 3 retries, exponential 1s→30s per §9.3) wired into `HttpTransport.executeAsync` via a per-attempt loop using `CompletableFuture.delayedExecutor` (no scheduled threads). Network errors and HTTP 501–599 retry; 500 and 4xx do not.
73+
- §13.5 response object — `Response<T>` wrapper exposes typed `data()`, `rawBody()` (defensive copy), `statusCode()`, `requestUrl()`, `requestId()`, format predicates (`isJson()`/`isCsv()`/`isHtml()`), `isNoData()`, and `saveToFile(Path)`. Every resource endpoint returns `Response<T>` so consumers get a uniform surface regardless of format.
7174
- §15 packaging: SemVer, MIT `LICENSE`, `CHANGELOG.md` in Keep a Changelog format, version auto-detected via JAR manifest (`Implementation-Version`).
72-
- §16 security: tokens never logged verbatim (use `Tokens.redact`); TLS validated by default (`HttpClient` does not expose a skip-verify option).
75+
- §16 security: tokens never logged verbatim (use `Tokens.redact`); TLS validated by default (`HttpClient` does not expose a skip-verify option); query strings stripped from log output (logged as `/path?…`) so request params never persist to logs — exception context retains the full URI for diagnostic use; `EnvVars.systemLookup()` restricts reads to the declared `MARKETDATA_*` keys.
7376
- ADR-002 CI: split into four workflows.
7477
- `.github/workflows/pull-request.yml` — runs on PR `opened`/`synchronize`/`reopened` (no pre-PR push trigger by design). JDK 17 only. Runs `./gradlew build` (unit tests + Spotless + JaCoCo) and uploads coverage to Codecov. **Does not** run integration tests — those are handled by the on-demand workflow below.
7578
- `.github/workflows/main.yml` — runs only on `push` to `main`. Two jobs: `verify` does the full forward-compat matrix `{17, 21, 25}` for unit tests via `-PtestJdk=N`; `integration-tests` does a parallel matrix `{17, 21, 25}` against the live API. Both are mandatory for the merge to be considered successful. The JDK 17 matrix entry of `verify` also uploads coverage to Codecov as the new baseline that PRs compare against. `integration-tests` fails the build if `MARKETDATA_TOKEN` secret is absent (it is required on main).
@@ -79,21 +82,14 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements](
7982
- `-PtestJdk=N` is wired to **all** `Test` tasks (`test` and `integrationTest`) via `tasks.withType<Test>().configureEach { javaLauncher.set(...) }` in `build.gradle.kts`, so the matrix flag works uniformly across unit and integration tests.
8083
- Coverage ratchet lives in `codecov.yml`: project status with `target: auto, threshold: 5%` (cannot drop >5 pp vs base branch) plus a patch-coverage requirement of 70 % on new code. Requires a `CODECOV_TOKEN` repo secret — without it the upload step fails because workflows pass `fail_ci_if_error: true`.
8184

82-
**Deliberately deferred (require the request/endpoint layer to land first):**
83-
- §1.2 resource groupings (`client.stocks`, `client.options`, `client.funds`, `client.markets`, `client.utilities`).
84-
- §2 endpoint method coverage; §3 universal parameters; §11 wire-format decoding.
85-
- §5 actual `/user/` startup validation call (the `validateOnStartup` flag is the seam; the call itself comes with the request layer).
86-
- §7 honoring `MARKETDATA_LOGGING_LEVEL` and the spec's exact `{timestamp} - {logger_name} - {level} - {message}` format. Currently the SDK uses `java.util.logging` with default formatting; consumers can attach their own handler.
87-
- §8 rate-limit header parsing, pre-flight check, request-scoped attachment.
88-
- §9 `/status/` cache workflow and `Retry-After` header override (retry/backoff itself lives in `RetryPolicy` and is wired; what is missing is the `/status/` pre-check before retrying 501–599 and respecting the server-specified `Retry-After` over the calculated exponential backoff).
89-
- §13 100% coverage threshold via JaCoCo `violationRules`; deferred until there is functional code worth the threshold.
85+
**Deliberately deferred (require the per-resource layer to land first):**
86+
- §1.2 resource groupings — only `client.utilities()` is wired today; `client.stocks`, `client.options`, `client.funds`, `client.markets` still to come.
87+
- §2 endpoint method coverage; §3 universal parameters; §11 wire-format decoding for resources beyond utilities. The plumbing (`ParallelArrays.zip` helper for the parallel-arrays shape, `JsonResponseParser`, `Response<T>` wrapper) is in place — each new endpoint just declares its fields and row builder.
88+
- §8 request-scoped rate-limit attachment — today the snapshot is client-level (via `client.getRateLimits()`); attaching a per-response snapshot to `Response<T>` is a small follow-up when a consumer needs it.
89+
- §13 100% coverage threshold via JaCoCo `violationRules`; deferred until the resource layer lands so the threshold meaningfully ratchets functional code, not just scaffolding.
9090

9191
When picking up new work, check this list before reaching for the SDK requirements doc — most foundational rules are already encoded in code; missing pieces are deferred deliberately, not by accident.
9292

93-
**Known latent gaps:**
94-
- `HttpTransport.buildUri` URL-encodes query-param values with `URLEncoder.encode(..., UTF_8)`, which is form-encoding semantics: spaces become `+`, not `%20`. Fine for today's typed params (dates, numerics) but a future endpoint that takes an arbitrary string (e.g. `symbol="BRK A"`) would round-trip differently against an RFC-3986-strict server. Switch to a path/query-segment-aware encoder when the first such param lands. Tracked as Issue #10 of the 2026-05-11 review.
95-
- `Retry-After` server header is parsed and respected by neither `RetryPolicy` nor `HttpTransport`. Today every retry uses the calculated exponential backoff (`min(1s × 2^N, 30s)`). Implementing the override needs the response headers to reach `RetryPolicy.backoffDelay`, which today only sees the attempt index — most natural path is to surface a `Duration` on `ServerError` (or thread it through a separate channel) when 5xx responses carry the header. Follow-up of the §9 work.
96-
9793
## Acceptance checklist
9894

9995
`docs/java-sdk-requirements.md` ends with an "Acceptance Checklist" mapping each Java-specific requirements section to verifiable items. Treat it as the definition of done for v1: when implementing, work toward making each box checkable, and use it as a self-review pass before declaring a section complete.

Makefile

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Convenience wrapper around ./gradlew and the consumer-test / mock-server.
2+
# Run `make` (no args) or `make help` for the target list.
3+
4+
CONSUMER_DIR := examples/consumer-test
5+
MOCK_DIR := examples/mock-server
6+
7+
.DEFAULT_GOAL := help
8+
9+
# ---------------------------------------------------------------------------
10+
# Help
11+
# ---------------------------------------------------------------------------
12+
13+
.PHONY: help
14+
help: ## Show this help
15+
@awk 'BEGIN {FS = ":.*##"; printf "Usage:\n make \033[36m<target>\033[0m\n\nTargets:\n"} \
16+
/^# ===/ { in_section = 1; next } \
17+
/^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 } \
18+
/^## ---/ { printf "\n\033[33m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST)
19+
@echo ""
20+
@echo "Typical workflow:"
21+
@echo " make publish # publish SDK to mavenLocal"
22+
@echo " make mock-server # in another terminal"
23+
@echo " make demo-config # in a third terminal"
24+
25+
# ---------------------------------------------------------------------------
26+
## --- SDK build ---
27+
# ---------------------------------------------------------------------------
28+
29+
.PHONY: build
30+
build: ## Full build: unit tests + Spotless + JaCoCo (JDK 17)
31+
./gradlew build
32+
33+
.PHONY: test
34+
test: ## Unit tests only
35+
./gradlew test
36+
37+
.PHONY: spotless
38+
spotless: ## Apply code formatting
39+
./gradlew spotlessApply
40+
41+
.PHONY: clean
42+
clean: ## Clean all Gradle outputs (SDK + consumer-test)
43+
./gradlew clean
44+
cd $(CONSUMER_DIR) && ./gradlew clean
45+
46+
.PHONY: publish
47+
publish: ## Publish SDK to ~/.m2 (prereq for any demo)
48+
./gradlew publishToMavenLocal
49+
50+
# ---------------------------------------------------------------------------
51+
## --- Mock server ---
52+
# ---------------------------------------------------------------------------
53+
54+
.PHONY: mock-server
55+
mock-server: ## Start the FastAPI mock server (blocks, Ctrl+C to stop)
56+
cd $(MOCK_DIR) && ./run.sh
57+
58+
# ---------------------------------------------------------------------------
59+
## --- Consumer demos (need `make publish` first) ---
60+
# ---------------------------------------------------------------------------
61+
62+
.PHONY: demo-quickstart
63+
demo-quickstart: ## Idiomatic per-resource usage tour (live API; grows as resources land)
64+
cd $(CONSUMER_DIR) && ./gradlew runQuickstart
65+
66+
.PHONY: demo-live
67+
demo-live: ## Live API smoke (needs MARKETDATA_TOKEN in examples/consumer-test/.env)
68+
cd $(CONSUMER_DIR) && ./gradlew runLive
69+
70+
.PHONY: demo-config
71+
demo-config: ## Demo mode, cascade, validation (needs mock-server)
72+
cd $(CONSUMER_DIR) && ./gradlew runDemoConfig
73+
74+
.PHONY: demo-exceptions
75+
demo-exceptions: ## Every MarketDataException subtype (needs mock-server)
76+
cd $(CONSUMER_DIR) && ./gradlew runExceptions
77+
78+
.PHONY: demo-retry
79+
demo-retry: ## Retry, Retry-After, preflight (needs mock-server)
80+
cd $(CONSUMER_DIR) && ./gradlew runRetry
81+
82+
.PHONY: demo-response
83+
demo-response: ## Response<T> surface features (needs mock-server)
84+
cd $(CONSUMER_DIR) && ./gradlew runResponse
85+
86+
.PHONY: demo-concurrency
87+
demo-concurrency: ## 50-permit semaphore (needs mock-server)
88+
cd $(CONSUMER_DIR) && ./gradlew runConcurrency
89+
90+
.PHONY: demos-all
91+
demos-all: ## Run every mock-server-based demo back-to-back (needs mock-server)
92+
cd $(CONSUMER_DIR) && ./gradlew runDemoConfig runExceptions runRetry runResponse runConcurrency

build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ tasks.register<JacocoReport>("jacocoAggregateReport") {
138138
// main's last value) is enforced in CI — see .github/workflows/pull-request.yml
139139
// and .github/scripts/check-coverage-delta.py. Not enforced locally so that
140140
// dev iteration isn't blocked while coverage is in flux.
141+
//
142+
// SDK requirements §15.3 mandates 100% line coverage with explicit ignore
143+
// comments on untestable lines. Target deferred until business resources land
144+
// and the defensive-guards cleanup pass can run together.
141145

142146
spotless {
143147
java {

0 commit comments

Comments
 (0)