diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ed211da..33eaadb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,16 +34,19 @@ jobs: - name: Checkout uses: actions/checkout@v4 - # Install both JDK 17 (for compilation) and the matrix JDK (for - # test execution). setup-java exports JAVA_HOME__; - # Gradle's toolchain auto-detection picks them up. - - name: Set up JDKs (compile=17, test=${{ matrix.java }}) + # Install both the matrix JDK (test execution) and JDK 17 (compile + + # Gradle daemon runtime). Order matters: setup-java sets JAVA_HOME + # to the LAST entry, and Gradle 8.12 only supports JDKs up to 23 as + # its own runtime — so JDK 17 must be last for matrix.java = 25. + # setup-java still exports JAVA_HOME__ for both, and + # setup-gradle registers them as toolchain candidates. + - name: Set up JDKs (test=${{ matrix.java }}, compile/daemon=17) uses: actions/setup-java@v4 with: distribution: temurin java-version: | - 17 ${{ matrix.java }} + 17 - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 @@ -72,8 +75,50 @@ jobs: files: build/reports/jacoco/test/jacocoTestReport.xml fail_ci_if_error: true - # Integration-tests job is intentionally not wired up yet: - # SDK requirements §13 says they run on PRs and release pipelines, but - # they hit the live API and require a MARKETDATA_TOKEN secret. Add this - # job (gated on `if: ${{ secrets.MARKETDATA_TOKEN != '' }}`) once the - # token is configured in the repo's GitHub Actions secrets. + # SDK requirements §13: integration tests run mandatorily on every push + # to main (release-pipeline gate) on the full forward-compat matrix. + # On PRs they're triggered on demand instead — see + # pr-integration-on-demand.yml. + integration-tests: + name: Integration tests (live API, JDK ${{ matrix.java }}) + runs-on: ubuntu-latest + needs: verify + strategy: + fail-fast: false + matrix: + java: ['17', '21', '25'] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDKs (test=${{ matrix.java }}, compile/daemon=17) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: | + ${{ matrix.java }} + 17 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run integration tests against live API + env: + MARKETDATA_TOKEN: ${{ secrets.MARKETDATA_TOKEN }} + MARKETDATA_RUN_INTEGRATION_TESTS: 'true' + run: | + if [ -z "$MARKETDATA_TOKEN" ]; then + echo "::error::MARKETDATA_TOKEN secret is required on main; integration tests must run on every merge." + exit 1 + fi + ./gradlew integrationTest -PtestJdk=${{ matrix.java }} --stacktrace + + - name: Upload integration-test reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: integration-test-reports-jdk${{ matrix.java }} + path: | + build/reports/tests/integrationTest/ + build/test-results/integrationTest/ + retention-days: 14 diff --git a/.github/workflows/pr-integration-on-demand.yml b/.github/workflows/pr-integration-on-demand.yml new file mode 100644 index 0000000..8d013e7 --- /dev/null +++ b/.github/workflows/pr-integration-on-demand.yml @@ -0,0 +1,205 @@ +name: Integration tests on demand + +# Manually triggered by commenting on an open PR: +# `integrationtest` → JDK 17 only +# `integrationtestfull` → full matrix {17, 21, 25} +# +# Integration tests hit the live Market Data API, so we don't run them +# automatically on every PR open/sync (saves API quota + CI minutes). +# They ARE required for merge — branch protection on `main` should list +# "Integration tests pass" as a required status check, which is the +# aggregator job below. PRs cannot merge until a reviewer comments one +# of the two trigger phrases AND the resulting run is green. +# +# Important security note: workflows triggered by `issue_comment` always +# run from the *default branch's* version of the workflow file, not from +# the PR. Adding/changing this file on a feature branch has no effect +# until it lands on main. +on: + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write # to react and post the result comment + +# Multiple trigger comments on the same PR cancel earlier runs. +concurrency: + group: pr-integration-on-demand-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + guard: + name: Guard + runs-on: ubuntu-latest + # Only fire on PR comments (not generic issue comments) that contain + # one of the two accepted slash-style commands. `contains` is + # substring match — note that `integrationtest` is itself a substring + # of `integrationtestfull`, so this OR matches both, and the matrix + # decision below disambiguates. + if: | + github.event.issue.pull_request != null && ( + contains(github.event.comment.body, 'integrationtest') || + contains(github.event.comment.body, 'integrationtestfull') + ) + outputs: + head_sha: ${{ steps.pr.outputs.head_sha }} + jdks: ${{ steps.matrix.outputs.jdks }} + mode: ${{ steps.matrix.outputs.mode }} + steps: + - name: Verify commenter has write permission + uses: actions/github-script@v7 + with: + script: | + const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.payload.comment.user.login, + }); + const allowed = ['write', 'maintain', 'admin'].includes(perm.permission); + if (!allowed) { + core.setFailed( + `@${context.payload.comment.user.login} (${perm.permission}) ` + + `cannot trigger integration tests; write access required.` + ); + } + + - name: React 👀 to the trigger comment + uses: actions/github-script@v7 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'eyes', + }); + + - name: Resolve PR head SHA + id: pr + uses: actions/github-script@v7 + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.issue.number, + }); + if (pr.state !== 'open') { + core.setFailed(`PR #${pr.number} is ${pr.state}; refusing to run.`); + return; + } + core.setOutput('head_sha', pr.head.sha); + + - name: Decide JDK matrix from comment body + id: matrix + env: + BODY: ${{ github.event.comment.body }} + run: | + # Check for the long form first because it contains 'integrationtest' as a substring. + if [[ "$BODY" == *"integrationtestfull"* ]]; then + echo 'jdks=["17","21","25"]' >> "$GITHUB_OUTPUT" + echo 'mode=full' >> "$GITHUB_OUTPUT" + echo "Trigger: integrationtestfull → matrix {17, 21, 25}" + else + echo 'jdks=["17"]' >> "$GITHUB_OUTPUT" + echo 'mode=single' >> "$GITHUB_OUTPUT" + echo "Trigger: integrationtest → JDK 17" + fi + + integration-tests: + name: Integration tests (JDK ${{ matrix.java }}) + needs: guard + runs-on: ubuntu-latest + strategy: + # Don't cancel siblings: if JDK 21 fails, we still want 17 and 25 + # results to surface. + fail-fast: false + matrix: + java: ${{ fromJSON(needs.guard.outputs.jdks) }} + + steps: + # Check out exactly the PR's HEAD commit so we test the proposed + # change, not the merge ref. + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ needs.guard.outputs.head_sha }} + + # Order matters: JDK 17 must be last so JAVA_HOME=17 (Gradle 8.12 + # only supports JDKs up to 23 as its daemon runtime). The matrix + # JDK is still installed and registered as a toolchain target. + - name: Set up JDKs (test=${{ matrix.java }}, compile/daemon=17) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: | + ${{ matrix.java }} + 17 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run integration tests against live API + env: + MARKETDATA_TOKEN: ${{ secrets.MARKETDATA_TOKEN }} + MARKETDATA_RUN_INTEGRATION_TESTS: 'true' + run: | + if [ -z "$MARKETDATA_TOKEN" ]; then + echo "::error::MARKETDATA_TOKEN secret missing — cannot run integration tests." + exit 1 + fi + ./gradlew integrationTest -PtestJdk=${{ matrix.java }} --stacktrace + + - name: Upload integration-test reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: integration-test-reports-jdk${{ matrix.java }} + path: | + build/reports/tests/integrationTest/ + build/test-results/integrationTest/ + retention-days: 14 + + # Aggregator job. Branch protection on `main` should require this + # check name ("Integration tests pass") so a single required check + # covers both `integrationtest` (matrix=[17]) and `integrationtestfull` + # (matrix=[17,21,25]) modes uniformly. Without this, branch protection + # would have to list the per-matrix-entry check names which only exist + # in the `full` mode. + required-check: + name: Integration tests pass + needs: [guard, integration-tests] + if: always() && needs.guard.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Aggregate matrix outcome + env: + MATRIX_RESULT: ${{ needs.integration-tests.result }} + MODE: ${{ needs.guard.outputs.mode }} + run: | + echo "Mode: $MODE" + echo "Matrix outcome: $MATRIX_RESULT" + if [[ "$MATRIX_RESULT" != "success" ]]; then + echo "::error::One or more integration-test JDK entries failed." + exit 1 + fi + echo "All integration tests passed." + + - name: Comment outcome on the PR + if: always() + uses: actions/github-script@v7 + with: + script: | + const ok = '${{ needs.integration-tests.result }}' === 'success'; + const mode = '${{ needs.guard.outputs.mode }}'; + const emoji = ok ? '✅' : '❌'; + const status = ok ? 'passed' : 'failed'; + const matrix = mode === 'full' ? '`{17, 21, 25}`' : '`17`'; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + body: `${emoji} On-demand integration tests on JDK ${matrix} ${status}. [View run](${runUrl}).`, + }); diff --git a/.github/workflows/pr-matrix-on-demand.yml b/.github/workflows/pr-matrix-on-demand.yml index 6c309ad..5dda271 100644 --- a/.github/workflows/pr-matrix-on-demand.yml +++ b/.github/workflows/pr-matrix-on-demand.yml @@ -110,14 +110,16 @@ jobs: with: ref: ${{ needs.guard.outputs.head_sha }} - # Compile=17, test=matrix JDK; same shape as main.yml. - - name: Set up JDKs (compile=17, test=${{ matrix.java }}) + # Order matters: JDK 17 must be last so JAVA_HOME=17 (Gradle 8.12 + # doesn't support JDK 24+ as its daemon runtime). The matrix JDK + # is still installed and registered as a toolchain target. + - name: Set up JDKs (test=${{ matrix.java }}, compile/daemon=17) uses: actions/setup-java@v4 with: distribution: temurin java-version: | - 17 ${{ matrix.java }} + 17 - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 0ced2ec..4411957 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -63,3 +63,10 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: build/reports/jacoco/test/jacocoTestReport.xml fail_ci_if_error: true + + # NOTE: integration tests are NOT run automatically on PR open/sync. + # They are required for merge but only fire when a reviewer comments + # `integrationtest` (JDK 17) or `integrationtestfull` (matrix 17/21/25) + # — see .github/workflows/pr-integration-on-demand.yml. Branch-protection + # rules should require the "Integration tests pass" check name produced + # by that workflow before allowing merge to main. diff --git a/CLAUDE.md b/CLAUDE.md index 39e7e02..06d1625 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ These decisions are not up for debate without amending the corresponding ADR: - **Java only.** Single artifact, no Kotlin sources. Published JAR must not bring `kotlin-stdlib` as a transitive dep. JSpecify is compile-time only and doesn't count. (ADR-001) - **Kotlin consumers are first-class via interop, not via a Kotlin artifact.** A separate `marketdata-sdk-java-kotlin` extensions JAR (Option E) is deferred. (ADR-001) - **JDK 17 minimum.** Build with `javac --release 17`; no multi-release JAR. CI test matrix is `{17, 21, 25}` for forward-compat. (ADR-002) -- **Gradle, Kotlin DSL.** `build.gradle.kts`, `settings.gradle.kts`, version catalog at `gradle/libs.versions.toml`. Standard plugins: `java-library`, `maven-publish`, Vanniktech Maven Publish (or Gradle Nexus Publish), Spotless, JaCoCo. Integration tests live in a separate `integrationTest` source set, env-var-gated. (ADR-003) +- **Gradle 9.0, Kotlin DSL.** `build.gradle.kts`, `settings.gradle.kts`, version catalog at `gradle/libs.versions.toml`. Wrapper pinned to **Gradle 9.0.0** — the first release that supports **JDK 25** for both daemon and toolchain (8.x maxed at JDK 24). The daemon still runs on JDK 17 via `JAVA_HOME` for stability (CI workflows order `setup-java`'s `java-version` so the matrix JDK is first and 17 is last); toolchain forks compile/test JDKs as needed via `-PtestJdk=N`. Standard plugins: `java-library`, `maven-publish`, Vanniktech Maven Publish (or Gradle Nexus Publish), Spotless, JaCoCo. Integration tests live in a separate `integrationTest` source set, env-var-gated. (ADR-003) - **`java.net.http.HttpClient` exclusively.** No third-party HTTP client (OkHttp, Apache) as a runtime dep — ever. HTTP/2 on (default). One shared `HttpClient` per `MarketDataClient`. Timeouts: 99s request, 2s connect. (ADR-004) - **Jackson (`jackson-databind`) for JSON.** Records-based response models (Jackson record support, 2.12+). The API's parallel-arrays wire format (e.g. `{"s":"ok","symbol":["AAPL","MSFT"],"price":[150.0,400.0]}`) is decoded via custom `JsonDeserializer` classes, *not* default reflection. Jackson is **not shaded** in v1; shading is held in reserve. (ADR-005) - **Sync + async parity per endpoint.** Every public endpoint exposes both `quote(...)` and `quoteAsync(...)`; async returns `CompletableFuture`. **Internal logic is async-first.** Sync methods are thin wrappers that call `.join()` and unwrap `CompletionException` to surface the underlying cause directly. Both surfaces share validation, retry, rate-limit, and concurrency-pool logic — no parallel implementations. Tests must cover both variants for every endpoint. (ADR-006) @@ -68,10 +68,13 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements]( - §12 concurrency: `Semaphore(50)` field on `MarketDataClient` (wiring of acquire/release lands with the request layer). - §15 packaging: SemVer, MIT `LICENSE`, `CHANGELOG.md` in Keep a Changelog format, version auto-detected via JAR manifest (`Implementation-Version`). - §16 security: tokens never logged verbatim (use `Tokens.redact`); TLS validated by default (`HttpClient` does not expose a skip-verify option). -- ADR-002 CI: split into three workflows. - - `.github/workflows/pull-request.yml` — runs on PR `opened`/`synchronize`/`reopened` (no pre-PR push trigger by design). JDK 17 only. Runs `./gradlew build` and uploads `build/reports/jacoco/test/jacocoTestReport.xml` to Codecov. - - `.github/workflows/main.yml` — runs only on `push` to `main`. Full forward-compat matrix `{17, 21, 25}` via `-PtestJdk=N` (wired into `tasks.test.javaLauncher` in `build.gradle.kts`). The JDK 17 matrix entry also uploads coverage to Codecov, establishing the base coverage that PRs compare against. - - `.github/workflows/pr-matrix-on-demand.yml` — manually triggered by commenting one of `/run-all-jdks`, `/jdk-matrix`, or `/test-all` on an open PR. Runs JDK 21 and 25 (17 already ran via `pull-request.yml`). Gated to commenters with write/maintain/admin permission. Reacts 👀 to the trigger comment and posts a result summary comment when the matrix finishes. Note: `issue_comment` workflows always execute from the default branch's copy of the file — feature-branch edits to this workflow have no effect until merged to main. +- ADR-002 CI: split into four workflows. + - `.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. + - `.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). + - `.github/workflows/pr-matrix-on-demand.yml` — manually triggered on a PR by commenting `/run-all-jdks`, `/jdk-matrix`, or `/test-all`. Runs the **unit-test** matrix on JDK 21 and 25 (17 already ran via `pull-request.yml`). Gated to write/maintain/admin commenters. Reacts 👀 to the trigger comment and posts a result summary. + - `.github/workflows/pr-integration-on-demand.yml` — manually triggered on a PR by commenting `integrationtest` (JDK 17 only) or `integrationtestfull` (matrix `{17, 21, 25}`). Runs the **integration-test** suite against the live API. Same write+ permission gate as the matrix-on-demand workflow. Aggregates the matrix outcome into a single required check named **"Integration tests pass"** so branch protection can require it uniformly regardless of which command was used. Branch-protection rules on `main` should list this check as required for merge. + - All four `issue_comment`-driven workflows execute from the default branch's copy of their YAML, not the PR's. Feature-branch edits to these workflows take effect only after merge to main. + - `-PtestJdk=N` is wired to **all** `Test` tasks (`test` and `integrationTest`) via `tasks.withType().configureEach { javaLauncher.set(...) }` in `build.gradle.kts`, so the matrix flag works uniformly across unit and integration tests. - 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`. **Deliberately deferred (require the request/endpoint layer to land first):** @@ -83,7 +86,6 @@ The Java SDK must also satisfy the canonical, cross-language [SDK Requirements]( - §9 retry/backoff policy and `/status/` cache workflow. - §12 acquire/release of the concurrency semaphore around dispatched requests. - §13 100% coverage threshold via JaCoCo `violationRules`; deferred until there is functional code worth the threshold. -- §13 integration-test CI job: stubbed at the bottom of `ci.yml` with a comment. Gating on a `MARKETDATA_TOKEN` GitHub Actions secret; will be wired up once the secret exists. 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. diff --git a/build.gradle.kts b/build.gradle.kts index 995403f..1ab8216 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,7 +41,12 @@ tasks.jar { } // ADR-003: integration tests live in a separate, env-var-gated source set. -val integrationTest by sourceSets.creating +val integrationTest by sourceSets.creating { + // Wire the main and unit-test outputs into the integration test classpath + // so ITs can use both production code and test helpers (junit, assertj). + compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output + runtimeClasspath += output + compileClasspath +} val integrationTestImplementation by configurations.getting { extendsFrom(configurations.testImplementation.get()) @@ -81,18 +86,19 @@ dependencies { tasks.test { useJUnitPlatform() finalizedBy(tasks.jacocoTestReport) +} - // ADR-002 CI matrix: optionally run tests on a specific JDK while - // compilation stays pinned to --release 17. The CI workflow passes - // -PtestJdk=17|21|25; locally you can do ./gradlew test -PtestJdk=21 - // (Gradle will provision the JDK via the foojay resolver if missing). - val testJdk = providers.gradleProperty("testJdk").orNull - if (testJdk != null) { - javaLauncher.set( - javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(testJdk.toInt()) - } - ) +// ADR-002 CI matrix: optionally run any Test task on a specific JDK +// while compilation stays pinned to --release 17. The flag is wired to +// every Test task (unit `test` + `integrationTest`) so on-demand and +// merge-to-main matrix runs cover the live API on JDK 17/21/25 too. +val testJdkProperty = providers.gradleProperty("testJdk").orNull +if (testJdkProperty != null) { + val launcher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(testJdkProperty.toInt()) + } + tasks.withType().configureEach { + javaLauncher.set(launcher) } } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b9..8bdaf60 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cea7a79..2a84e18 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index f3b75f3..ef07e01 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -114,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -205,7 +205,7 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. @@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 9b42019..5eed7ee 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,11 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/src/integrationTest/java/com/marketdata/sdk/PlaceholderIT.java b/src/integrationTest/java/com/marketdata/sdk/PlaceholderIT.java deleted file mode 100644 index b336e45..0000000 --- a/src/integrationTest/java/com/marketdata/sdk/PlaceholderIT.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.marketdata.sdk; - -import org.junit.jupiter.api.Test; - -/** - * Placeholder so the {@code integrationTest} source set compiles before any real integration tests - * exist. Replace with live-API tests in subsequent iterations. Gated by {@code - * MARKETDATA_RUN_INTEGRATION_TESTS=true}. - */ -class PlaceholderIT { - - @Test - void sourceSetCompiles() { - // Intentionally empty. - } -} diff --git a/src/integrationTest/java/com/marketdata/sdk/markets/MarketsStatusIT.java b/src/integrationTest/java/com/marketdata/sdk/markets/MarketsStatusIT.java new file mode 100644 index 0000000..5c46e90 --- /dev/null +++ b/src/integrationTest/java/com/marketdata/sdk/markets/MarketsStatusIT.java @@ -0,0 +1,52 @@ +package com.marketdata.sdk.markets; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.marketdata.sdk.MarketDataClient; +import java.time.LocalDate; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +/** + * Integration test against the live Market Data API. Gated by the {@code integrationTest} source + * set, which itself only runs when {@code MARKETDATA_RUN_INTEGRATION_TESTS=true} is exported (see + * {@code build.gradle.kts}). + * + *

Requires a valid {@code MARKETDATA_TOKEN} env var (or {@code .env} entry). Without one the + * client enters demo mode and the {@code /markets/status/} endpoint is not on the demo allow-list, + * so the test would receive an {@code AuthenticationException}. + * + *

Each scenario runs once for {@link CallMode#SYNC} and once for {@link CallMode#ASYNC} so we + * satisfy SDK requirements §13's "tests must cover both sync and async variants for every endpoint" + * against the real wire. + */ +class MarketsStatusIT { + + @ParameterizedTest + @EnumSource(CallMode.class) + void todayStatusReturnsAtLeastOneEntry(CallMode mode) { + try (var client = MarketDataClient.builder().validateOnStartup(false).build()) { + MarketStatus status = mode.statusNoArgs(client.markets()); + + // The endpoint always returns at least one entry for "today" — even on weekends/holidays + // there's a row with status="closed". + assertThat(status.days()).isNotEmpty(); + assertThat(status.days().get(0).date()).isNotNull(); + } + } + + @ParameterizedTest + @EnumSource(CallMode.class) + void historicalRangeReturnsExpectedDays(CallMode mode) { + LocalDate from = LocalDate.now().minusDays(7); + LocalDate to = LocalDate.now().minusDays(1); + + try (var client = MarketDataClient.builder().validateOnStartup(false).build()) { + MarketStatus status = mode.statusForRange(client.markets(), from, to); + + assertThat(status.days()).hasSizeBetween(1, 7); + assertThat(status.days()) + .allSatisfy(d -> assertThat(d.date()).isBetween(from.minusDays(1), to.plusDays(1))); + } + } +} diff --git a/src/integrationTest/java/com/marketdata/sdk/utilities/UtilitiesHeadersIT.java b/src/integrationTest/java/com/marketdata/sdk/utilities/UtilitiesHeadersIT.java new file mode 100644 index 0000000..8d2e624 --- /dev/null +++ b/src/integrationTest/java/com/marketdata/sdk/utilities/UtilitiesHeadersIT.java @@ -0,0 +1,85 @@ +package com.marketdata.sdk.utilities; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.marketdata.sdk.MarketDataClient; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +/** + * Hits the live {@code GET /headers/} endpoint (root-level, no {@code /v1/} prefix). The endpoint + * echoes back the headers the API received from us, so this is the strongest end-to-end check that + * the SDK is actually sending the right Authorization, User-Agent, etc. + * + *

Gated by the {@code integrationTest} source set ({@code + * MARKETDATA_RUN_INTEGRATION_TESTS=true}); requires a valid {@code MARKETDATA_TOKEN}. + * + *

Each scenario runs once for sync and once for async per SDK requirements §13. + */ +class UtilitiesHeadersIT { + + @ParameterizedTest + @EnumSource(CallMode.class) + void headersEchoesUserAgentAndAuthorization(CallMode mode) { + try (var client = MarketDataClient.builder().validateOnStartup(false).build()) { + RequestHeaders headers = mode.headers(client.utilities()); + + assertThat(headers.isEmpty()).isFalse(); + + // The SDK's User-Agent must be sent on every request (SDK requirements §1.1): + assertThat(headers.get("user-agent")) + .as("the API echoes back the User-Agent we sent") + .get() + .asString() + .startsWith("marketdata-sdk-java/"); + + // Authorization is forwarded (and partially redacted in the response per the API docs): + assertThat(headers.get("authorization")) + .as("Authorization header was sent and echoed (redacted)") + .get() + .asString() + .startsWith("Bearer "); + + // Accept comes from buildRequest in HttpTransport: + assertThat(headers.get("accept")).get().asString().contains("application/json"); + } + } + + @ParameterizedTest + @EnumSource(CallMode.class) + void headerLookupsAreCaseInsensitive(CallMode mode) { + try (var client = MarketDataClient.builder().validateOnStartup(false).build()) { + RequestHeaders headers = mode.headers(client.utilities()); + + // The API normalizes keys to lowercase but our deserializer also forces lowercase, so + // any of these capitalizations should resolve to the same value. + assertThat(headers.get("User-Agent")).isEqualTo(headers.get("user-agent")); + assertThat(headers.get("USER-AGENT")).isEqualTo(headers.get("user-agent")); + } + } + + enum CallMode { + SYNC { + @Override + RequestHeaders headers(UtilitiesResource r) { + return r.headers(); + } + }, + ASYNC { + @Override + RequestHeaders headers(UtilitiesResource r) { + try { + return r.headersAsync().join(); + } catch (CompletionException e) { + if (e.getCause() instanceof RuntimeException re) { + throw re; + } + throw e; + } + } + }; + + abstract RequestHeaders headers(UtilitiesResource r); + } +} diff --git a/src/integrationTest/java/com/marketdata/sdk/utilities/UtilitiesStatusIT.java b/src/integrationTest/java/com/marketdata/sdk/utilities/UtilitiesStatusIT.java new file mode 100644 index 0000000..a159faf --- /dev/null +++ b/src/integrationTest/java/com/marketdata/sdk/utilities/UtilitiesStatusIT.java @@ -0,0 +1,80 @@ +package com.marketdata.sdk.utilities; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.marketdata.sdk.MarketDataClient; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +/** + * Hits the live {@code GET /status/} endpoint (root-level, no {@code /v1/} prefix). Gated by the + * {@code integrationTest} source set ({@code MARKETDATA_RUN_INTEGRATION_TESTS=true}). + * + *

This endpoint does not require authentication, but the SDK will still send the {@code + * Authorization} header if a token is configured — the API simply ignores it for status lookups. We + * don't validate that here because there's no observable difference. + * + *

Each scenario runs once for sync and once for async per SDK requirements §13. + */ +class UtilitiesStatusIT { + + @ParameterizedTest + @EnumSource(CallMode.class) + void statusReturnsServicesWithStructuralInvariants(CallMode mode) { + try (var client = MarketDataClient.builder().validateOnStartup(false).build()) { + ServiceStatus status = mode.status(client.utilities()); + + assertThat(status.services()).isNotEmpty(); + assertThat(status.services()) + .allSatisfy( + s -> { + assertThat(s.service()).isNotBlank(); + assertThat(s.status()).isIn("online", "offline"); + // online flag is consistent with the human-readable status: + assertThat(s.online()).isEqualTo("online".equals(s.status())); + assertThat(s.uptimePct30d()).isBetween(0.0, 1.0); + assertThat(s.uptimePct90d()).isBetween(0.0, 1.0); + assertThat(s.updated()).isNotNull(); + }); + } + } + + @ParameterizedTest + @EnumSource(CallMode.class) + void statusUrlIsRootLevelNotV1(CallMode mode) { + // No direct way to introspect the URL from a successful call, but the + // fact that the call doesn't 404 (which it would if we mistakenly hit + // /v1/status/) is the implicit assertion. If this passes, the + // RequestSpec.getAtRoot wiring is correctly skipping the version + // prefix. + try (var client = MarketDataClient.builder().validateOnStartup(false).build()) { + ServiceStatus result = mode.status(client.utilities()); + assertThat(result).isNotNull(); + } + } + + enum CallMode { + SYNC { + @Override + ServiceStatus status(UtilitiesResource r) { + return r.status(); + } + }, + ASYNC { + @Override + ServiceStatus status(UtilitiesResource r) { + try { + return r.statusAsync().join(); + } catch (CompletionException e) { + if (e.getCause() instanceof RuntimeException re) { + throw re; + } + throw e; + } + } + }; + + abstract ServiceStatus status(UtilitiesResource r); + } +} diff --git a/src/integrationTest/java/com/marketdata/sdk/utilities/UtilitiesUserIT.java b/src/integrationTest/java/com/marketdata/sdk/utilities/UtilitiesUserIT.java new file mode 100644 index 0000000..dcf56fb --- /dev/null +++ b/src/integrationTest/java/com/marketdata/sdk/utilities/UtilitiesUserIT.java @@ -0,0 +1,72 @@ +package com.marketdata.sdk.utilities; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.marketdata.sdk.MarketDataClient; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +/** + * Hits the live {@code GET /user/} endpoint and validates the round-trip. Gated by the {@code + * integrationTest} source set ({@code MARKETDATA_RUN_INTEGRATION_TESTS=true}); requires a valid + * {@code MARKETDATA_TOKEN}. + * + *

Each scenario runs once for sync and once for async per SDK requirements §13. + */ +class UtilitiesUserIT { + + @ParameterizedTest + @EnumSource(CallMode.class) + void userReturnsAccountSnapshot(CallMode mode) { + try (var client = MarketDataClient.builder().validateOnStartup(false).build()) { + UserInfo info = mode.user(client.utilities()); + + // We don't know exact quota numbers (depends on the test account / time of day), + // but the structural invariants hold for any well-formed account. + assertThat(info.requestsLimit()).isPositive(); + assertThat(info.requestsRemaining()).isGreaterThanOrEqualTo(0); + assertThat(info.requestsRemaining()).isLessThanOrEqualTo(info.requestsLimit()); + assertThat(info.optionsDataPermissions()).isNotNull(); + } + } + + @ParameterizedTest + @EnumSource(CallMode.class) + void afterUserCallTheClientHasAFreshRateLimitSnapshot(CallMode mode) { + try (var client = MarketDataClient.builder().validateOnStartup(false).build()) { + assertThat(client.getRateLimits()).isNull(); + + mode.user(client.utilities()); + + // §8.1: x-api-ratelimit-* headers populate the snapshot on the way back. + assertThat(client.getRateLimits()).isNotNull(); + assertThat(client.getRateLimits().limit()).isPositive(); + } + } + + /** Mirrors the test-side {@code CallMode} from the unit suite. */ + enum CallMode { + SYNC { + @Override + UserInfo user(UtilitiesResource r) { + return r.user(); + } + }, + ASYNC { + @Override + UserInfo user(UtilitiesResource r) { + try { + return r.userAsync().join(); + } catch (CompletionException e) { + if (e.getCause() instanceof RuntimeException re) { + throw re; + } + throw e; + } + } + }; + + abstract UserInfo user(UtilitiesResource r); + } +} diff --git a/src/main/java/com/marketdata/sdk/MarketDataClient.java b/src/main/java/com/marketdata/sdk/MarketDataClient.java index b4ab403..9e8fa64 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataClient.java +++ b/src/main/java/com/marketdata/sdk/MarketDataClient.java @@ -4,10 +4,10 @@ import com.marketdata.sdk.internal.EnvVars; import com.marketdata.sdk.internal.Tokens; import com.marketdata.sdk.internal.Version; -import java.net.http.HttpClient; +import com.marketdata.sdk.internal.http.HttpTransport; +import com.marketdata.sdk.markets.MarketsResource; +import com.marketdata.sdk.utilities.UtilitiesResource; import java.time.Duration; -import java.util.concurrent.Semaphore; -import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; import java.util.logging.Logger; import org.jspecify.annotations.Nullable; @@ -15,31 +15,30 @@ /** * 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. + *

One {@code MarketDataClient} per application. Resource façades (e.g. {@link #markets()}) are + * accessed through the client; all HTTP-shaped concerns (connection pooling, HTTP/2, the global + * concurrency semaphore, rate-limit header parsing) live in the internal {@link HttpTransport} the + * client owns. * - *

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). + *

Construction follows the configuration cascade in SDK requirements §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). */ public final class MarketDataClient implements AutoCloseable { /** SDK requirements §10: fixed 99-second per-request timeout. */ - public static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(99); + public static final Duration REQUEST_TIMEOUT = HttpTransport.REQUEST_TIMEOUT; /** SDK requirements §10: fixed 2-second connect timeout. */ - public static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(2); + public static final Duration CONNECT_TIMEOUT = HttpTransport.CONNECT_TIMEOUT; /** SDK requirements §12: maximum concurrent in-flight requests per client. */ - public static final int CONCURRENCY_LIMIT = 50; + public static final int CONCURRENCY_LIMIT = HttpTransport.CONCURRENCY_LIMIT; private static final Logger LOG = Logger.getLogger(MarketDataClient.class.getName()); - private final HttpClient httpClient; - private final Semaphore concurrencyPermits; - private final AtomicReference<@Nullable RateLimits> latestRateLimits = new AtomicReference<>(); + private final HttpTransport transport; private final @Nullable String token; private final String baseUrl; @@ -48,6 +47,12 @@ public final class MarketDataClient implements AutoCloseable { private final boolean demoMode; private final boolean validateOnStartup; + // Resources — lazily? eagerly? eager keeps the API simple, the cost is + // one record-shaped object per resource group. Worth revisiting if the + // resource count grows large. + private final MarketsResource markets; + private final UtilitiesResource utilities; + private MarketDataClient(Builder builder) { Configuration config = Configuration.loadFromProcess(); this.token = config.resolve(builder.apiKey, EnvVars.TOKEN); @@ -62,13 +67,9 @@ private MarketDataClient(Builder builder) { this.validateOnStartup = builder.validateOnStartup; this.userAgent = "marketdata-sdk-java/" + Version.current(); - this.httpClient = - HttpClient.newBuilder() - .connectTimeout(CONNECT_TIMEOUT) - .version(HttpClient.Version.HTTP_2) - .followRedirects(HttpClient.Redirect.NORMAL) - .build(); - this.concurrencyPermits = new Semaphore(CONCURRENCY_LIMIT); + this.transport = new HttpTransport(this.baseUrl, this.apiVersion, this.userAgent, this.token); + this.markets = new MarketsResource(this.transport); + this.utilities = new UtilitiesResource(this.transport); LOG.log( Level.INFO, @@ -82,14 +83,38 @@ private MarketDataClient(Builder builder) { LOG.log(Level.FINE, "Token: {0}", Tokens.redact(token)); } - // SDK requirements §5: validate on startup by default. The actual - // /user/ call lands with the request layer; this flag is the seam. + // SDK requirements §5: validate the token by hitting /user/ unless either + // (a) the caller explicitly disabled it, or (b) we're in demo mode (no + // token to validate). This also populates the rate-limit snapshot at + // construction time per §8.1 — the response headers feed the transport's + // latestRateLimits as a side-effect of the call. + if (validateOnStartup && !demoMode) { + this.utilities.user(); + } } public static Builder builder() { return new Builder(); } + // --------------------------------------------------------------------- + // Resource accessors + // --------------------------------------------------------------------- + + /** Façade for the {@code /v1/markets/*} endpoint group. */ + public MarketsResource markets() { + return markets; + } + + /** Façade for the {@code utilities} resource group ({@code /v1/user/}, plus more later). */ + public UtilitiesResource utilities() { + return utilities; + } + + // --------------------------------------------------------------------- + // Configuration accessors + // --------------------------------------------------------------------- + public String getBaseUrl() { return baseUrl; } @@ -112,15 +137,12 @@ public boolean isValidateOnStartup() { /** Latest client-level rate-limit snapshot, or {@code null} if none has been received yet. */ public @Nullable RateLimits getRateLimits() { - return latestRateLimits.get(); + return transport.getLatestRateLimits(); } @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+. + transport.close(); } private static String trimTrailingSlash(String url) { diff --git a/src/main/java/com/marketdata/sdk/exception/AuthenticationError.java b/src/main/java/com/marketdata/sdk/exception/AuthenticationError.java deleted file mode 100644 index 6efa76f..0000000 --- a/src/main/java/com/marketdata/sdk/exception/AuthenticationError.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.marketdata.sdk.exception; - -import org.jspecify.annotations.Nullable; - -/** The API rejected the credentials (HTTP 401). */ -public final class AuthenticationError extends MarketDataException { - - public AuthenticationError(String message, ErrorContext context) { - super(message, context, null); - } - - public AuthenticationError(String message, ErrorContext context, @Nullable Throwable cause) { - super(message, context, cause); - } -} diff --git a/src/main/java/com/marketdata/sdk/exception/AuthenticationException.java b/src/main/java/com/marketdata/sdk/exception/AuthenticationException.java new file mode 100644 index 0000000..cf6f965 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/exception/AuthenticationException.java @@ -0,0 +1,15 @@ +package com.marketdata.sdk.exception; + +import org.jspecify.annotations.Nullable; + +/** The API rejected the credentials (HTTP 401). */ +public final class AuthenticationException extends MarketDataException { + + public AuthenticationException(String message, ErrorContext context) { + super(message, context, null); + } + + public AuthenticationException(String message, ErrorContext context, @Nullable Throwable cause) { + super(message, context, cause); + } +} diff --git a/src/main/java/com/marketdata/sdk/exception/BadRequestError.java b/src/main/java/com/marketdata/sdk/exception/BadRequestError.java deleted file mode 100644 index 9e4ae68..0000000 --- a/src/main/java/com/marketdata/sdk/exception/BadRequestError.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.marketdata.sdk.exception; - -import org.jspecify.annotations.Nullable; - -/** The request was malformed or invalid (HTTP 400 / 422). */ -public final class BadRequestError extends MarketDataException { - - public BadRequestError(String message, ErrorContext context) { - super(message, context, null); - } - - public BadRequestError(String message, ErrorContext context, @Nullable Throwable cause) { - super(message, context, cause); - } -} diff --git a/src/main/java/com/marketdata/sdk/exception/BadRequestException.java b/src/main/java/com/marketdata/sdk/exception/BadRequestException.java new file mode 100644 index 0000000..c05d1ed --- /dev/null +++ b/src/main/java/com/marketdata/sdk/exception/BadRequestException.java @@ -0,0 +1,15 @@ +package com.marketdata.sdk.exception; + +import org.jspecify.annotations.Nullable; + +/** The request was malformed or invalid (HTTP 400 / 422). */ +public final class BadRequestException extends MarketDataException { + + public BadRequestException(String message, ErrorContext context) { + super(message, context, null); + } + + public BadRequestException(String message, ErrorContext context, @Nullable Throwable cause) { + super(message, context, cause); + } +} diff --git a/src/main/java/com/marketdata/sdk/exception/MarketDataException.java b/src/main/java/com/marketdata/sdk/exception/MarketDataException.java index f5b427b..d49a546 100644 --- a/src/main/java/com/marketdata/sdk/exception/MarketDataException.java +++ b/src/main/java/com/marketdata/sdk/exception/MarketDataException.java @@ -16,13 +16,13 @@ * any HTTP request is dispatched. */ public abstract sealed class MarketDataException extends RuntimeException - permits AuthenticationError, - BadRequestError, - NotFoundError, - RateLimitError, - ServerError, - NetworkError, - ParseError { + permits AuthenticationException, + BadRequestException, + NotFoundException, + RateLimitException, + ServerException, + NetworkException, + ParseException { private static final ZoneId EASTERN = ZoneId.of("America/New_York"); private static final DateTimeFormatter TIMESTAMP_FORMAT = diff --git a/src/main/java/com/marketdata/sdk/exception/NetworkError.java b/src/main/java/com/marketdata/sdk/exception/NetworkException.java similarity index 52% rename from src/main/java/com/marketdata/sdk/exception/NetworkError.java rename to src/main/java/com/marketdata/sdk/exception/NetworkException.java index 8d318de..e6e6574 100644 --- a/src/main/java/com/marketdata/sdk/exception/NetworkError.java +++ b/src/main/java/com/marketdata/sdk/exception/NetworkException.java @@ -3,13 +3,13 @@ import org.jspecify.annotations.Nullable; /** Transport-level failure: connection refused, DNS error, timeout, TLS, etc. */ -public final class NetworkError extends MarketDataException { +public final class NetworkException extends MarketDataException { - public NetworkError(String message, ErrorContext context) { + public NetworkException(String message, ErrorContext context) { super(message, context, null); } - public NetworkError(String message, ErrorContext context, @Nullable Throwable cause) { + public NetworkException(String message, ErrorContext context, @Nullable Throwable cause) { super(message, context, cause); } } diff --git a/src/main/java/com/marketdata/sdk/exception/NotFoundError.java b/src/main/java/com/marketdata/sdk/exception/NotFoundException.java similarity index 65% rename from src/main/java/com/marketdata/sdk/exception/NotFoundError.java rename to src/main/java/com/marketdata/sdk/exception/NotFoundException.java index 3f050cd..a563459 100644 --- a/src/main/java/com/marketdata/sdk/exception/NotFoundError.java +++ b/src/main/java/com/marketdata/sdk/exception/NotFoundException.java @@ -9,13 +9,13 @@ * than throwing this exception. It exists for the cases where 404 truly indicates a programming * error. */ -public final class NotFoundError extends MarketDataException { +public final class NotFoundException extends MarketDataException { - public NotFoundError(String message, ErrorContext context) { + public NotFoundException(String message, ErrorContext context) { super(message, context, null); } - public NotFoundError(String message, ErrorContext context, @Nullable Throwable cause) { + public NotFoundException(String message, ErrorContext context, @Nullable Throwable cause) { super(message, context, cause); } } diff --git a/src/main/java/com/marketdata/sdk/exception/ParseError.java b/src/main/java/com/marketdata/sdk/exception/ParseException.java similarity index 51% rename from src/main/java/com/marketdata/sdk/exception/ParseError.java rename to src/main/java/com/marketdata/sdk/exception/ParseException.java index 205c59c..222cd3e 100644 --- a/src/main/java/com/marketdata/sdk/exception/ParseError.java +++ b/src/main/java/com/marketdata/sdk/exception/ParseException.java @@ -3,13 +3,13 @@ import org.jspecify.annotations.Nullable; /** The API response could not be decoded into the expected model. */ -public final class ParseError extends MarketDataException { +public final class ParseException extends MarketDataException { - public ParseError(String message, ErrorContext context) { + public ParseException(String message, ErrorContext context) { super(message, context, null); } - public ParseError(String message, ErrorContext context, @Nullable Throwable cause) { + public ParseException(String message, ErrorContext context, @Nullable Throwable cause) { super(message, context, cause); } } diff --git a/src/main/java/com/marketdata/sdk/exception/RateLimitError.java b/src/main/java/com/marketdata/sdk/exception/RateLimitError.java deleted file mode 100644 index ba4ca54..0000000 --- a/src/main/java/com/marketdata/sdk/exception/RateLimitError.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.marketdata.sdk.exception; - -import org.jspecify.annotations.Nullable; - -/** The client exceeded the API's rate limit (HTTP 429). */ -public final class RateLimitError extends MarketDataException { - - public RateLimitError(String message, ErrorContext context) { - super(message, context, null); - } - - public RateLimitError(String message, ErrorContext context, @Nullable Throwable cause) { - super(message, context, cause); - } -} diff --git a/src/main/java/com/marketdata/sdk/exception/RateLimitException.java b/src/main/java/com/marketdata/sdk/exception/RateLimitException.java new file mode 100644 index 0000000..28ab357 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/exception/RateLimitException.java @@ -0,0 +1,15 @@ +package com.marketdata.sdk.exception; + +import org.jspecify.annotations.Nullable; + +/** The client exceeded the API's rate limit (HTTP 429). */ +public final class RateLimitException extends MarketDataException { + + public RateLimitException(String message, ErrorContext context) { + super(message, context, null); + } + + public RateLimitException(String message, ErrorContext context, @Nullable Throwable cause) { + super(message, context, cause); + } +} diff --git a/src/main/java/com/marketdata/sdk/exception/ServerError.java b/src/main/java/com/marketdata/sdk/exception/ServerError.java deleted file mode 100644 index 8ae929b..0000000 --- a/src/main/java/com/marketdata/sdk/exception/ServerError.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.marketdata.sdk.exception; - -import org.jspecify.annotations.Nullable; - -/** The API returned a 5xx response. */ -public final class ServerError extends MarketDataException { - - public ServerError(String message, ErrorContext context) { - super(message, context, null); - } - - public ServerError(String message, ErrorContext context, @Nullable Throwable cause) { - super(message, context, cause); - } -} diff --git a/src/main/java/com/marketdata/sdk/exception/ServerException.java b/src/main/java/com/marketdata/sdk/exception/ServerException.java new file mode 100644 index 0000000..c034df1 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/exception/ServerException.java @@ -0,0 +1,15 @@ +package com.marketdata.sdk.exception; + +import org.jspecify.annotations.Nullable; + +/** The API returned a 5xx response. */ +public final class ServerException extends MarketDataException { + + public ServerException(String message, ErrorContext context) { + super(message, context, null); + } + + public ServerException(String message, ErrorContext context, @Nullable Throwable cause) { + super(message, context, cause); + } +} diff --git a/src/main/java/com/marketdata/sdk/internal/http/HttpStatusMapper.java b/src/main/java/com/marketdata/sdk/internal/http/HttpStatusMapper.java new file mode 100644 index 0000000..2e0bd1d --- /dev/null +++ b/src/main/java/com/marketdata/sdk/internal/http/HttpStatusMapper.java @@ -0,0 +1,37 @@ +package com.marketdata.sdk.internal.http; + +import com.marketdata.sdk.exception.AuthenticationException; +import com.marketdata.sdk.exception.BadRequestException; +import com.marketdata.sdk.exception.ErrorContext; +import com.marketdata.sdk.exception.MarketDataException; +import com.marketdata.sdk.exception.RateLimitException; +import com.marketdata.sdk.exception.ServerException; +import org.jspecify.annotations.Nullable; + +/** + * Maps an HTTP status code to the {@link MarketDataException} subtype the SDK requirements doc §9.1 + * mandates. + * + *

Note that 200 / 203 (success) and 404 (no-data sentinel returned by the API as {@code + * {"s":"no_data"}}) are not handled here — those status codes mean "got a body, + * decode it" and the resource layer interprets them. This mapper only fires on hard failures. + */ +final class HttpStatusMapper { + + private HttpStatusMapper() {} + + static MarketDataException toException( + int status, String requestUrl, @Nullable String requestId) { + ErrorContext ctx = new ErrorContext(emptyToNull(requestId), requestUrl, status); + return switch (status) { + case 400, 422 -> new BadRequestException("HTTP " + status + ": invalid request", ctx); + case 401 -> new AuthenticationException("HTTP 401: invalid or missing API token", ctx); + case 429 -> new RateLimitException("HTTP 429: rate limit exceeded", ctx); + default -> new ServerException("HTTP " + status + ": server error", ctx); + }; + } + + private static @Nullable String emptyToNull(@Nullable String s) { + return (s == null || s.isBlank()) ? null : s; + } +} diff --git a/src/main/java/com/marketdata/sdk/internal/http/HttpTransport.java b/src/main/java/com/marketdata/sdk/internal/http/HttpTransport.java new file mode 100644 index 0000000..87fcd78 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/internal/http/HttpTransport.java @@ -0,0 +1,217 @@ +package com.marketdata.sdk.internal.http; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.marketdata.sdk.RateLimits; +import com.marketdata.sdk.exception.ErrorContext; +import com.marketdata.sdk.exception.MarketDataException; +import com.marketdata.sdk.exception.NetworkException; +import com.marketdata.sdk.exception.ParseException; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicReference; +import org.jspecify.annotations.Nullable; + +/** + * The single point of contact between resource façades and the network. + * + *

Owned by {@code MarketDataClient}, instantiated once per client. All HTTP-shaped concerns live + * here so resources never see a {@link HttpClient}, an {@link ObjectMapper}, the concurrency + * semaphore, or the rate-limit headers — they get a {@link RequestSpec} in and a typed domain + * object out. + * + *

Per ADR-006 the design is async-first: {@link #executeAsync} is the canonical path; {@link + * #executeSync} is a thin wrapper that calls {@link CompletableFuture#join()} and unwraps any + * {@link CompletionException} so the caller sees the underlying cause directly. + */ +public final class HttpTransport implements AutoCloseable { + + /** SDK requirements §10: fixed 99-second per-request timeout. */ + public static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(99); + + /** SDK requirements §10: fixed 2-second connect timeout. */ + public static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(2); + + /** SDK requirements §12: 50-permit global concurrency pool. */ + public static final int CONCURRENCY_LIMIT = 50; + + private static final String CF_RAY = "cf-ray"; + + private final HttpClient httpClient; + private final ObjectMapper jsonMapper; + private final Semaphore concurrencyPermits; + private final AtomicReference<@Nullable RateLimits> latestRateLimits = new AtomicReference<>(); + + private final String baseUrl; + private final String apiVersion; + private final String userAgent; + private final @Nullable String token; + + public HttpTransport( + String baseUrl, String apiVersion, String userAgent, @Nullable String token) { + this.baseUrl = baseUrl; + this.apiVersion = apiVersion; + this.userAgent = userAgent; + this.token = token; + this.concurrencyPermits = new Semaphore(CONCURRENCY_LIMIT); + // Be lenient on unknown JSON properties: the API may add new response + // fields over time, and we don't want SDK consumers to start seeing + // ParseException the moment a backend ships a new field. Records that + // need every field strictly mapped opt back in via a custom + // @JsonDeserialize anyway (see the wire-format deserializers). + this.jsonMapper = + new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + this.httpClient = + HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .version(HttpClient.Version.HTTP_2) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + /** Latest client-level rate-limit snapshot, or {@code null} if no request has succeeded yet. */ + public @Nullable RateLimits getLatestRateLimits() { + return latestRateLimits.get(); + } + + /** + * Async-first request execution. + * + *

Acquires a concurrency permit, fires the request, parses rate-limit headers, decodes the + * body when the status is 200/203/404 (the API returns 404 with {@code {"s":"no_data"}} as a + * sentinel — see SDK requirements §9.1), and translates other status codes to the appropriate + * {@link MarketDataException} subtype. + */ + public CompletableFuture executeAsync(RequestSpec spec, Class responseType) { + URI uri = buildUri(spec); + HttpRequest request = buildRequest(uri); + + try { + concurrencyPermits.acquire(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return CompletableFuture.failedFuture( + new NetworkException( + "Interrupted while waiting for a concurrency permit", + new ErrorContext(null, uri.toString(), null), + e)); + } + + return httpClient + .sendAsync(request, BodyHandlers.ofByteArray()) + .whenComplete((r, t) -> concurrencyPermits.release()) + .handle( + (response, error) -> { + if (error != null) { + Throwable root = unwrap(error); + throw new CompletionException( + new NetworkException( + "Request to " + uri + " failed: " + root.getMessage(), + new ErrorContext(null, uri.toString(), null), + root)); + } + latestRateLimits.set(RateLimitHeaders.parse(response.headers())); + return processResponse(response, responseType, uri.toString()); + }); + } + + /** + * Sync wrapper around {@link #executeAsync}. Per ADR-006, calls {@code .join()} and unwraps + * {@link CompletionException} so callers see the underlying {@link MarketDataException} directly. + */ + public T executeSync(RequestSpec spec, Class responseType) { + try { + return executeAsync(spec, responseType).join(); + } catch (CompletionException e) { + Throwable cause = e.getCause(); + if (cause instanceof MarketDataException mde) { + throw mde; + } + if (cause instanceof RuntimeException re) { + throw re; + } + throw new NetworkException("Unexpected failure invoking SDK", ErrorContext.empty(), cause); + } + } + + @Override + public void close() { + // java.net.http.HttpClient gained explicit close() in JDK 21; until + // the SDK's minimum bumps to 21+ this is a no-op (ADR-002). + } + + private T processResponse(HttpResponse response, Class responseType, String url) { + int status = response.statusCode(); + String requestId = response.headers().firstValue(CF_RAY).orElse(null); + + // 200 OK + 203 Non-Authoritative + 404 (with {"s":"no_data"} body) all + // carry a JSON payload the resource wants to decode. Other statuses + // mean we never got a usable body — translate to a typed exception. + if (status == 200 || status == 203 || status == 404) { + try { + return jsonMapper.readValue(response.body(), responseType); + } catch (IOException e) { + throw new ParseException( + "Failed to decode response from " + url + ": " + e.getMessage(), + new ErrorContext(requestId, url, status), + e); + } + } + throw HttpStatusMapper.toException(status, url, requestId); + } + + private URI buildUri(RequestSpec spec) { + StringBuilder sb = new StringBuilder(); + sb.append(baseUrl).append('/'); + if (spec.versioned()) { + sb.append(apiVersion).append('/'); + } + sb.append(spec.path()); + if (!spec.path().endsWith("/")) { + sb.append('/'); + } + Map params = spec.queryParams(); + if (!params.isEmpty()) { + sb.append('?'); + boolean first = true; + for (Map.Entry e : params.entrySet()) { + if (!first) { + sb.append('&'); + } + sb.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8)) + .append('=') + .append(URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)); + first = false; + } + } + return URI.create(sb.toString()); + } + + private HttpRequest buildRequest(URI uri) { + HttpRequest.Builder b = + HttpRequest.newBuilder(uri) + .GET() + .timeout(REQUEST_TIMEOUT) + .header("User-Agent", userAgent) + .header("Accept", "application/json"); + if (token != null) { + b.header("Authorization", "Bearer " + token); + } + return b.build(); + } + + private static Throwable unwrap(Throwable t) { + return (t instanceof CompletionException && t.getCause() != null) ? t.getCause() : t; + } +} diff --git a/src/main/java/com/marketdata/sdk/internal/http/RateLimitHeaders.java b/src/main/java/com/marketdata/sdk/internal/http/RateLimitHeaders.java new file mode 100644 index 0000000..bea538c --- /dev/null +++ b/src/main/java/com/marketdata/sdk/internal/http/RateLimitHeaders.java @@ -0,0 +1,53 @@ +package com.marketdata.sdk.internal.http; + +import com.marketdata.sdk.RateLimits; +import java.net.http.HttpHeaders; +import java.time.Instant; +import org.jspecify.annotations.Nullable; + +/** + * Parses the {@code x-api-ratelimit-*} response headers that the API sets on every successful + * request (SDK requirements §8.2) into a {@link RateLimits} record. + * + *

Returns {@code null} when none of the relevant headers are present, which happens during a + * rate-limit-tracking outage on the server side (the API silently swallows the error and keeps + * serving the request, see {@code request_rate_middleware.py:30–40}). + */ +final class RateLimitHeaders { + + private static final String LIMIT = "x-api-ratelimit-limit"; + private static final String REMAINING = "x-api-ratelimit-remaining"; + private static final String RESET = "x-api-ratelimit-reset"; + private static final String CONSUMED = "x-api-ratelimit-consumed"; + + private RateLimitHeaders() {} + + static @Nullable RateLimits parse(HttpHeaders headers) { + Long limit = readLong(headers, LIMIT); + Long remaining = readLong(headers, REMAINING); + Long reset = readLong(headers, RESET); + Long consumed = readLong(headers, CONSUMED); + if (limit == null && remaining == null && reset == null && consumed == null) { + return null; + } + return new RateLimits( + limit != null ? limit : 0L, + remaining != null ? remaining : 0L, + Instant.ofEpochSecond(reset != null ? reset : 0L), + consumed != null ? consumed : 0L); + } + + private static @Nullable Long readLong(HttpHeaders headers, String name) { + return headers + .firstValue(name) + .map( + v -> { + try { + return Long.parseLong(v.trim()); + } catch (NumberFormatException e) { + return null; + } + }) + .orElse(null); + } +} diff --git a/src/main/java/com/marketdata/sdk/internal/http/RequestSpec.java b/src/main/java/com/marketdata/sdk/internal/http/RequestSpec.java new file mode 100644 index 0000000..ee9d241 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/internal/http/RequestSpec.java @@ -0,0 +1,69 @@ +package com.marketdata.sdk.internal.http; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Declarative description of an HTTP GET request the SDK wants to make. + * + *

Resources build instances of this and hand them to {@link HttpTransport}; the transport is the + * only code that knows about base URLs, auth headers, timeouts, and the like. + * + *

Paths come in two flavors. The default ({@link #get}) is API-versioned: the transport + * prepends the configured API version (e.g. {@code /v1/}). The {@link #getAtRoot} variant produces + * a path directly under {@code baseUrl} — used by the handful of utility endpoints that sit outside + * the version-prefixed surface (e.g. {@code /status/}, {@code /headers/}). + * + * @param path path segment with no leading slash and no trailing slash, e.g. {@code + * "markets/status"} or {@code "status"}. The transport adds the rest. + * @param queryParams ordered query parameters (insertion order preserved for predictable URLs in + * tests). Values are URL-encoded by the transport. + * @param versioned whether to prepend the configured API version to the path (default true) + */ +public record RequestSpec(String path, Map queryParams, boolean versioned) { + + public RequestSpec { + // Preserve insertion order — Map.copyOf would defensively copy but + // strip the iteration order, which breaks predictable URLs in tests + // and in any caller that cares about query-param order on the wire. + queryParams = Collections.unmodifiableMap(new LinkedHashMap<>(queryParams)); + } + + /** Builds a request against the API-versioned surface (default — most endpoints). */ + public static Builder get(String path) { + return new Builder(path, true); + } + + /** + * Builds a request against the root surface, bypassing the version prefix. Used for the small set + * of utility endpoints documented under {@code https://api.marketdata.app//} rather than + * {@code /v1//}. + */ + public static Builder getAtRoot(String path) { + return new Builder(path, false); + } + + public static final class Builder { + private final String path; + private final boolean versioned; + private final Map queryParams = new LinkedHashMap<>(); + + private Builder(String path, boolean versioned) { + this.path = path; + this.versioned = versioned; + } + + /** Adds a query parameter only if {@code value} is non-null. */ + public Builder query(String key, Object value) { + if (value != null) { + queryParams.put(key, value.toString()); + } + return this; + } + + public RequestSpec build() { + return new RequestSpec(path, Collections.unmodifiableMap(queryParams), versioned); + } + } +} diff --git a/src/main/java/com/marketdata/sdk/internal/http/package-info.java b/src/main/java/com/marketdata/sdk/internal/http/package-info.java new file mode 100644 index 0000000..dbdd57b --- /dev/null +++ b/src/main/java/com/marketdata/sdk/internal/http/package-info.java @@ -0,0 +1,10 @@ +/** + * Internal HTTP transport layer. Reusable across every endpoint in the SDK — handles URL + * construction, auth headers, the global concurrency semaphore, response decoding, rate-limit + * header parsing, and the mapping of HTTP status codes to {@link + * com.marketdata.sdk.exception.MarketDataException} subtypes. + */ +@NullMarked +package com.marketdata.sdk.internal.http; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/com/marketdata/sdk/internal/wire/markets/MarketStatusDeserializer.java b/src/main/java/com/marketdata/sdk/internal/wire/markets/MarketStatusDeserializer.java new file mode 100644 index 0000000..e47023e --- /dev/null +++ b/src/main/java/com/marketdata/sdk/internal/wire/markets/MarketStatusDeserializer.java @@ -0,0 +1,81 @@ +package com.marketdata.sdk.internal.wire.markets; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.marketdata.sdk.markets.DailyStatus; +import com.marketdata.sdk.markets.MarketStatus; +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; + +/** + * Jackson deserializer for the {@code /v1/markets/status/} parallel-arrays wire format. + * + *

Wire shape (success): + * + *

{@code
+ * { "s": "ok",
+ *   "date":   [1706745600, 1706832000, 1706918400],
+ *   "status": ["open", "open", "closed"] }
+ * }
+ * + *

Wire shape (no data, also returned for non-US countries by design): + * + *

{@code
+ * { "s": "no_data" }
+ * }
+ * + *

The deserializer expands the parallel arrays into a list of {@link DailyStatus} (one per + * index), normalizes the unix timestamps to {@link LocalDate} in US/Eastern (SDK requirements + * §11.4), and represents {@code "no_data"} as an empty list. + */ +public final class MarketStatusDeserializer extends JsonDeserializer { + + private static final ZoneId EASTERN = ZoneId.of("America/New_York"); + private static final String STATUS_OK = "ok"; + private static final String STATUS_NO_DATA = "no_data"; + + @Override + public MarketStatus deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode root = p.readValueAsTree(); + String s = root.path("s").asText(""); + + if (STATUS_NO_DATA.equals(s)) { + return new MarketStatus(List.of()); + } + if (!STATUS_OK.equals(s)) { + throw new IOException( + "Unexpected status field in /markets/status response: '" + + s + + "' (expected 'ok' or 'no_data')"); + } + + JsonNode dates = root.path("date"); + JsonNode statuses = root.path("status"); + if (!dates.isArray() || !statuses.isArray()) { + throw new IOException( + "Malformed /markets/status response: expected 'date' and 'status' arrays"); + } + if (dates.size() != statuses.size()) { + throw new IOException( + "Malformed /markets/status response: 'date' and 'status' arrays have different sizes (" + + dates.size() + + " vs " + + statuses.size() + + ")"); + } + + List days = new ArrayList<>(dates.size()); + for (int i = 0; i < dates.size(); i++) { + LocalDate date = Instant.ofEpochSecond(dates.get(i).asLong()).atZone(EASTERN).toLocalDate(); + boolean open = "open".equalsIgnoreCase(statuses.get(i).asText()); + days.add(new DailyStatus(date, open)); + } + return new MarketStatus(List.copyOf(days)); + } +} diff --git a/src/main/java/com/marketdata/sdk/internal/wire/markets/package-info.java b/src/main/java/com/marketdata/sdk/internal/wire/markets/package-info.java new file mode 100644 index 0000000..f0d68f6 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/internal/wire/markets/package-info.java @@ -0,0 +1,9 @@ +/** + * Wire-format adapters for the {@code /v1/markets/*} endpoint group. Translates the API's + * parallel-arrays JSON (SDK requirements §11.1) into the typed records exposed by {@link + * com.marketdata.sdk.markets}. + */ +@NullMarked +package com.marketdata.sdk.internal.wire.markets; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/com/marketdata/sdk/internal/wire/utilities/RequestHeadersDeserializer.java b/src/main/java/com/marketdata/sdk/internal/wire/utilities/RequestHeadersDeserializer.java new file mode 100644 index 0000000..0884945 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/internal/wire/utilities/RequestHeadersDeserializer.java @@ -0,0 +1,38 @@ +package com.marketdata.sdk.internal.wire.utilities; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.marketdata.sdk.utilities.RequestHeaders; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +/** + * Jackson deserializer for {@code GET /headers/}. The response is a flat JSON object with arbitrary + * header names as keys — there's no fixed schema, so a record with named fields would not fit. + * Instead we collapse every top-level key/value pair into a single {@code Map} and let {@link + * RequestHeaders} expose case-insensitive lookups on top of it. + * + *

Keys are lower-cased here so {@link RequestHeaders#get} can do its case-insensitive lookup + * with a simple HashMap probe. + */ +public final class RequestHeadersDeserializer extends JsonDeserializer { + + @Override + public RequestHeaders deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode root = p.readValueAsTree(); + if (!root.isObject()) { + throw new IOException( + "Malformed /headers response: expected a JSON object, got " + root.getNodeType()); + } + Map headers = new LinkedHashMap<>(); + root.fields() + .forEachRemaining( + entry -> + headers.put(entry.getKey().toLowerCase(Locale.ROOT), entry.getValue().asText())); + return new RequestHeaders(Map.copyOf(headers)); + } +} diff --git a/src/main/java/com/marketdata/sdk/internal/wire/utilities/ServiceStatusDeserializer.java b/src/main/java/com/marketdata/sdk/internal/wire/utilities/ServiceStatusDeserializer.java new file mode 100644 index 0000000..1900dd1 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/internal/wire/utilities/ServiceStatusDeserializer.java @@ -0,0 +1,84 @@ +package com.marketdata.sdk.internal.wire.utilities; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.marketdata.sdk.utilities.ServiceHealth; +import com.marketdata.sdk.utilities.ServiceStatus; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** + * Jackson deserializer for the {@code GET /status/} parallel-arrays wire format. + * + *

Wire shape (success): + * + *

{@code
+ * { "s": "ok",
+ *   "service":      ["/v1/funds/candles/", "/v1/stocks/quotes/"],
+ *   "status":       ["online", "online"],
+ *   "online":       [true, true],
+ *   "uptimePct30d": [1.0, 0.998],
+ *   "uptimePct90d": [1.0, 0.997],
+ *   "updated":      [1734036832, 1734036832] }
+ * }
+ * + *

The deserializer expands the parallel arrays into a list of {@link ServiceHealth}, one per + * service, in input order. {@code "no_data"} produces an empty list. + */ +public final class ServiceStatusDeserializer extends JsonDeserializer { + + private static final String STATUS_OK = "ok"; + private static final String STATUS_NO_DATA = "no_data"; + + @Override + public ServiceStatus deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode root = p.readValueAsTree(); + String s = root.path("s").asText(""); + + if (STATUS_NO_DATA.equals(s)) { + return new ServiceStatus(List.of()); + } + if (!STATUS_OK.equals(s)) { + throw new IOException( + "Unexpected status field in /status response: '" + s + "' (expected 'ok' or 'no_data')"); + } + + JsonNode services = root.path("service"); + JsonNode statuses = root.path("status"); + JsonNode onlines = root.path("online"); + JsonNode uptime30 = root.path("uptimePct30d"); + JsonNode uptime90 = root.path("uptimePct90d"); + JsonNode updated = root.path("updated"); + + if (!services.isArray() || !statuses.isArray() || !onlines.isArray()) { + throw new IOException( + "Malformed /status response: expected 'service', 'status', and 'online' arrays"); + } + int n = services.size(); + if (statuses.size() != n + || onlines.size() != n + || uptime30.size() != n + || uptime90.size() != n + || updated.size() != n) { + throw new IOException( + "Malformed /status response: parallel-array sizes don't match (" + n + " expected)"); + } + + List result = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + result.add( + new ServiceHealth( + services.get(i).asText(), + onlines.get(i).asBoolean(), + statuses.get(i).asText(), + uptime30.get(i).asDouble(), + uptime90.get(i).asDouble(), + Instant.ofEpochSecond(updated.get(i).asLong()))); + } + return new ServiceStatus(List.copyOf(result)); + } +} diff --git a/src/main/java/com/marketdata/sdk/internal/wire/utilities/package-info.java b/src/main/java/com/marketdata/sdk/internal/wire/utilities/package-info.java new file mode 100644 index 0000000..c6b3b39 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/internal/wire/utilities/package-info.java @@ -0,0 +1,9 @@ +/** + * Wire-format adapters for the {@code utilities} resource group: the {@code /status/} parallel- + * arrays shape and the {@code /headers/} flat-object shape with arbitrary keys. Mirrors the + * structure of {@link com.marketdata.sdk.internal.wire.markets}. + */ +@NullMarked +package com.marketdata.sdk.internal.wire.utilities; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/com/marketdata/sdk/markets/DailyStatus.java b/src/main/java/com/marketdata/sdk/markets/DailyStatus.java new file mode 100644 index 0000000..2f20caa --- /dev/null +++ b/src/main/java/com/marketdata/sdk/markets/DailyStatus.java @@ -0,0 +1,13 @@ +package com.marketdata.sdk.markets; + +import java.time.LocalDate; + +/** + * Whether the market was open on a single trading day. + * + * @param date the calendar date in the exchange's local time zone (US/Eastern for the default + * country US, per SDK requirements §11.4) + * @param open {@code true} if the market session was open on that date, {@code false} if closed + * (weekend, holiday, etc.) + */ +public record DailyStatus(LocalDate date, boolean open) {} diff --git a/src/main/java/com/marketdata/sdk/markets/MarketStatus.java b/src/main/java/com/marketdata/sdk/markets/MarketStatus.java new file mode 100644 index 0000000..7818478 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/markets/MarketStatus.java @@ -0,0 +1,26 @@ +package com.marketdata.sdk.markets; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.marketdata.sdk.internal.wire.markets.MarketStatusDeserializer; +import java.util.List; + +/** + * Result of a {@code /v1/markets/status/} call: one {@link DailyStatus} per requested date, in + * chronological order. + * + *

The wire format the API returns is a compressed parallel-arrays JSON payload (per SDK + * requirements §11.1); the SDK expands it into this idiomatic typed shape via a custom Jackson + * deserializer (ADR-005). + * + *

An empty {@code days} list means the API responded with no data — either an HTTP 404 with + * {@code {"s":"no_data"}} or an unsupported country (currently only {@code US} returns data). + * + * @param days the per-day market status, never {@code null}; empty when the API has no data + */ +@JsonDeserialize(using = MarketStatusDeserializer.class) +public record MarketStatus(List days) { + + public boolean isEmpty() { + return days.isEmpty(); + } +} diff --git a/src/main/java/com/marketdata/sdk/markets/MarketsResource.java b/src/main/java/com/marketdata/sdk/markets/MarketsResource.java new file mode 100644 index 0000000..1172600 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/markets/MarketsResource.java @@ -0,0 +1,92 @@ +package com.marketdata.sdk.markets; + +import com.marketdata.sdk.internal.http.HttpTransport; +import com.marketdata.sdk.internal.http.RequestSpec; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.CompletableFuture; + +/** + * Façade for the {@code /v1/markets/*} endpoint group. + * + *

Per ADR-006 every endpoint exposes a sync and an {@code …Async} variant. Both share the same + * request-building code; the sync forms are thin wrappers around the async path. + * + *

Currently only {@code /v1/markets/status/} is implemented. Future markets-related endpoints + * (none planned today) would land here. + */ +public final class MarketsResource { + + private static final String STATUS_PATH = "markets/status"; + private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ISO_LOCAL_DATE; + + private final HttpTransport transport; + + /** + * Package-private: only {@link com.marketdata.sdk.MarketDataClient} constructs resources, so + * consumers get one via {@code client.markets()}. + */ + public MarketsResource(HttpTransport transport) { + this.transport = transport; + } + + /** + * Today's market status for US exchanges. Equivalent to {@code GET /v1/markets/status/}. + * + *

Sync. The async sibling is {@link #statusAsync()}. + */ + public MarketStatus status() { + return transport.executeSync(RequestSpec.get(STATUS_PATH).build(), MarketStatus.class); + } + + /** Async variant of {@link #status()}. */ + public CompletableFuture statusAsync() { + return transport.executeAsync(RequestSpec.get(STATUS_PATH).build(), MarketStatus.class); + } + + /** + * Market status for a single trading day. Equivalent to {@code GET + * /v1/markets/status/?date=YYYY-MM-DD}. + * + * @param date the trading day to look up; sent in ISO-8601 format + */ + public MarketStatus status(LocalDate date) { + return transport.executeSync(forDate(date), MarketStatus.class); + } + + /** Async variant of {@link #status(LocalDate)}. */ + public CompletableFuture statusAsync(LocalDate date) { + return transport.executeAsync(forDate(date), MarketStatus.class); + } + + /** + * Market status for a closed date range. Equivalent to {@code GET + * /v1/markets/status/?from=YYYY-MM-DD&to=YYYY-MM-DD}. Both endpoints are inclusive. + * + * @param from start of the range (inclusive) + * @param to end of the range (inclusive) + * @throws IllegalArgumentException if {@code from} is after {@code to} + */ + public MarketStatus status(LocalDate from, LocalDate to) { + return transport.executeSync(forRange(from, to), MarketStatus.class); + } + + /** Async variant of {@link #status(LocalDate, LocalDate)}. */ + public CompletableFuture statusAsync(LocalDate from, LocalDate to) { + return transport.executeAsync(forRange(from, to), MarketStatus.class); + } + + private static RequestSpec forDate(LocalDate date) { + return RequestSpec.get(STATUS_PATH).query("date", ISO_DATE.format(date)).build(); + } + + private static RequestSpec forRange(LocalDate from, LocalDate to) { + if (from.isAfter(to)) { + throw new IllegalArgumentException("from (" + from + ") must not be after to (" + to + ")"); + } + return RequestSpec.get(STATUS_PATH) + .query("from", ISO_DATE.format(from)) + .query("to", ISO_DATE.format(to)) + .build(); + } +} diff --git a/src/main/java/com/marketdata/sdk/markets/package-info.java b/src/main/java/com/marketdata/sdk/markets/package-info.java new file mode 100644 index 0000000..f515c22 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/markets/package-info.java @@ -0,0 +1,8 @@ +/** + * Public types for the {@code /v1/markets/*} endpoint group: {@link + * com.marketdata.sdk.markets.MarketsResource} (façade) and the domain records it returns. + */ +@NullMarked +package com.marketdata.sdk.markets; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/com/marketdata/sdk/utilities/RequestHeaders.java b/src/main/java/com/marketdata/sdk/utilities/RequestHeaders.java new file mode 100644 index 0000000..769c370 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/utilities/RequestHeaders.java @@ -0,0 +1,28 @@ +package com.marketdata.sdk.utilities; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.marketdata.sdk.internal.wire.utilities.RequestHeadersDeserializer; +import java.util.Map; +import java.util.Optional; + +/** + * Snapshot of the HTTP headers the API saw for the most recent {@code /headers/} request — useful + * for debugging proxies, auth, or User-Agent issues. + * + *

Header keys are normalized to lower-case by the API; sensitive values (notably {@code + * Authorization}) come back partially redacted. Lookups are case-insensitive. + * + * @param all the full set of headers; iteration order matches the API response + */ +@JsonDeserialize(using = RequestHeadersDeserializer.class) +public record RequestHeaders(Map all) { + + /** Case-insensitive header lookup. */ + public Optional get(String name) { + return Optional.ofNullable(all.get(name.toLowerCase())); + } + + public boolean isEmpty() { + return all.isEmpty(); + } +} diff --git a/src/main/java/com/marketdata/sdk/utilities/ServiceHealth.java b/src/main/java/com/marketdata/sdk/utilities/ServiceHealth.java new file mode 100644 index 0000000..039fb6f --- /dev/null +++ b/src/main/java/com/marketdata/sdk/utilities/ServiceHealth.java @@ -0,0 +1,21 @@ +package com.marketdata.sdk.utilities; + +import java.time.Instant; + +/** + * Health snapshot for a single service monitored by the API status endpoint. + * + * @param service path of the service being monitored, e.g. {@code "/v1/funds/candles/"} + * @param online {@code true} if the service is currently up + * @param status human-readable status — typically {@code "online"} or {@code "offline"} + * @param uptimePct30d uptime fraction over the last 30 days (0.0–1.0) + * @param uptimePct90d uptime fraction over the last 90 days (0.0–1.0) + * @param updated when the status snapshot was last refreshed + */ +public record ServiceHealth( + String service, + boolean online, + String status, + double uptimePct30d, + double uptimePct90d, + Instant updated) {} diff --git a/src/main/java/com/marketdata/sdk/utilities/ServiceStatus.java b/src/main/java/com/marketdata/sdk/utilities/ServiceStatus.java new file mode 100644 index 0000000..359bee9 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/utilities/ServiceStatus.java @@ -0,0 +1,31 @@ +package com.marketdata.sdk.utilities; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.marketdata.sdk.internal.wire.utilities.ServiceStatusDeserializer; +import java.util.List; + +/** + * Result of a {@code GET /status/} call: per-service health for everything the API monitors. + * + *

The wire format is the same parallel-arrays shape used by other parallel-arrays endpoints + * (each field is an array indexed by service); the SDK expands it into one {@link ServiceHealth} + * per service via a custom Jackson deserializer (ADR-005). + * + *

Per SDK requirements §9.5 this endpoint also feeds the retry workflow's status cache — the SDK + * uses it internally to decide whether to keep retrying when the API is reporting itself offline. + * That wiring is deferred until the retry layer lands. + * + * @param services per-service health, in the order returned by the API; empty if the API reported + * {@code "no_data"} + */ +@JsonDeserialize(using = ServiceStatusDeserializer.class) +public record ServiceStatus(List services) { + + public boolean allOnline() { + return !services.isEmpty() && services.stream().allMatch(ServiceHealth::online); + } + + public boolean isEmpty() { + return services.isEmpty(); + } +} diff --git a/src/main/java/com/marketdata/sdk/utilities/UserInfo.java b/src/main/java/com/marketdata/sdk/utilities/UserInfo.java new file mode 100644 index 0000000..0742433 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/utilities/UserInfo.java @@ -0,0 +1,28 @@ +package com.marketdata.sdk.utilities; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Account-level info returned by {@code GET /v1/user/}. + * + *

Unlike most SDK responses, this endpoint emits a flat JSON object with kebab-case keys instead + * of the parallel-arrays wire format used elsewhere. The {@link JsonProperty} annotations on the + * canonical-constructor parameters are what let Jackson's record support map the kebab-case keys to + * the camelCase record components — no custom deserializer needed. + * + *

Note that {@link #requestsRemaining} and {@link #requestsLimit} duplicate information that the + * SDK also reads from response headers ({@code x-api-ratelimit-*}) on every request and exposes via + * {@code MarketDataClient.getRateLimits()}. They are returned here as a snapshot at the moment + * {@code /user/} was called — useful for an explicit one-shot quota query at startup (see SDK + * requirements §8.1). + * + * @param requestsRemaining requests left in the current quota window + * @param requestsLimit total request quota for the current window + * @param optionsDataPermissions human-readable description of the account's options-data + * permissions; empty string for accounts with real-time OPRA access, otherwise a string like + * {@code "OPRA data delayed 15 minutes"} + */ +public record UserInfo( + @JsonProperty("x-ratelimit-requests-remaining") long requestsRemaining, + @JsonProperty("x-ratelimit-requests-limit") long requestsLimit, + @JsonProperty("x-options-data-permissions") String optionsDataPermissions) {} diff --git a/src/main/java/com/marketdata/sdk/utilities/UtilitiesResource.java b/src/main/java/com/marketdata/sdk/utilities/UtilitiesResource.java new file mode 100644 index 0000000..be140e9 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/utilities/UtilitiesResource.java @@ -0,0 +1,84 @@ +package com.marketdata.sdk.utilities; + +import com.marketdata.sdk.internal.http.HttpTransport; +import com.marketdata.sdk.internal.http.RequestSpec; +import java.util.concurrent.CompletableFuture; + +/** + * Façade for the {@code utilities} resource group covering all three methods named in SDK + * requirements §2.2: {@link #status}, {@link #headers}, {@link #user}. + * + *

Note that {@code /status/} and {@code /headers/} live at the API root (no {@code /v1/} + * prefix), unlike most endpoints — that's why their {@link RequestSpec} use {@link + * RequestSpec#getAtRoot} instead of the regular versioned {@link RequestSpec#get}. + * + *

Per ADR-006 every endpoint exposes a sync and an {@code …Async} variant. + */ +public final class UtilitiesResource { + + private static final String STATUS_PATH = "status"; + private static final String HEADERS_PATH = "headers"; + private static final String USER_PATH = "user"; + + private final HttpTransport transport; + + /** + * Constructed by {@code MarketDataClient}; consumers reach a {@code UtilitiesResource} via {@code + * client.utilities()}. + */ + public UtilitiesResource(HttpTransport transport) { + this.transport = transport; + } + + // ---------- /status/ — service health ---------- + + /** + * API service health snapshot. Equivalent to {@code GET /status/} (root-level, no {@code /v1/} + * prefix). Does not require authentication. + */ + public ServiceStatus status() { + return transport.executeSync(RequestSpec.getAtRoot(STATUS_PATH).build(), ServiceStatus.class); + } + + /** Async variant of {@link #status()}. */ + public CompletableFuture statusAsync() { + return transport.executeAsync(RequestSpec.getAtRoot(STATUS_PATH).build(), ServiceStatus.class); + } + + // ---------- /headers/ — debug echo ---------- + + /** + * Returns the HTTP headers the API saw on this request. Equivalent to {@code GET /headers/} + * (root-level). Useful for debugging proxies, auth, or User-Agent issues. + */ + public RequestHeaders headers() { + return transport.executeSync(RequestSpec.getAtRoot(HEADERS_PATH).build(), RequestHeaders.class); + } + + /** Async variant of {@link #headers()}. */ + public CompletableFuture headersAsync() { + return transport.executeAsync( + RequestSpec.getAtRoot(HEADERS_PATH).build(), RequestHeaders.class); + } + + // ---------- /user/ — account info (root path, no /v1/ prefix) ---------- + + /** + * Account-level info for the token in use. Equivalent to {@code GET /user/} — root-level, like + * {@link #status()} and {@link #headers()}. Despite serving user-scoped data, this endpoint lives + * outside the {@code /v1/} surface; the user viewset on the API is mounted under the admin router + * which binds to the root. + * + *

Used internally by {@code MarketDataClient} during construction when {@code + * validateOnStartup} is enabled (SDK requirements §5) — failure to reach this endpoint with a + * valid token produces an {@code AuthenticationException} from the constructor itself. + */ + public UserInfo user() { + return transport.executeSync(RequestSpec.getAtRoot(USER_PATH).build(), UserInfo.class); + } + + /** Async variant of {@link #user()}. */ + public CompletableFuture userAsync() { + return transport.executeAsync(RequestSpec.getAtRoot(USER_PATH).build(), UserInfo.class); + } +} diff --git a/src/main/java/com/marketdata/sdk/utilities/package-info.java b/src/main/java/com/marketdata/sdk/utilities/package-info.java new file mode 100644 index 0000000..2ca83a2 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/utilities/package-info.java @@ -0,0 +1,9 @@ +/** + * Public types for the {@code /v1/user/}, {@code /v1/status/}, and {@code /v1/headers/} endpoint + * group. SDK requirements §1.2 names this resource group {@code utilities} regardless of where the + * underlying endpoints sit in the API URL space. + */ +@NullMarked +package com.marketdata.sdk.utilities; + +import org.jspecify.annotations.NullMarked; diff --git a/src/test/java/com/marketdata/sdk/MarketDataClientStartupValidationTest.java b/src/test/java/com/marketdata/sdk/MarketDataClientStartupValidationTest.java new file mode 100644 index 0000000..b8f4638 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/MarketDataClientStartupValidationTest.java @@ -0,0 +1,150 @@ +package com.marketdata.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.marketdata.sdk.exception.AuthenticationException; +import com.marketdata.sdk.internal.Configuration; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Specifically exercises the SDK requirements §5 contract: when {@code validateOnStartup=true} (the + * default) and the client has a token, the constructor must call {@code GET /user/} and propagate + * any {@link AuthenticationException} or other failure directly to the caller. + */ +class MarketDataClientStartupValidationTest { + + private HttpServer server; + private RouteHandler handler; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + handler = new RouteHandler(); + server.createContext("/", handler); + server.start(); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + private MarketDataClient.Builder builder() { + return MarketDataClient.builder() + .apiKey("test-key") + .baseUrl("http://127.0.0.1:" + server.getAddress().getPort()); + // validateOnStartup left at its default (true) — that's the whole point. + } + + @Test + void validatesByCallingUserAtConstruction() { + handler.setResponse( + 200, + "{\"x-ratelimit-requests-remaining\":99999," + + "\"x-ratelimit-requests-limit\":100000," + + "\"x-options-data-permissions\":\"\"}", + List.of( + new String[] {"x-api-ratelimit-limit", "100000"}, + new String[] {"x-api-ratelimit-remaining", "99999"}, + new String[] {"x-api-ratelimit-reset", "1735689600"}, + new String[] {"x-api-ratelimit-consumed", "0"})); + + try (var client = builder().build()) { + assertThat(client.isValidateOnStartup()).isTrue(); + assertThat(handler.callsTo("/user/")).isEqualTo(1); + // §8.1: the rate-limit snapshot is populated as a side-effect of the startup call. + assertThat(client.getRateLimits()).isNotNull(); + assertThat(client.getRateLimits().limit()).isEqualTo(100_000L); + } + } + + @Test + void invalidTokenFailsConstructionWithAuthenticationException() { + handler.setResponse(401, "{}"); + + assertThatThrownBy(() -> builder().build()) + .isInstanceOf(AuthenticationException.class) + .satisfies( + t -> assertThat(((AuthenticationException) t).getRequestUrl()).contains("/user/")); + } + + @Test + void demoModeSkipsValidationEvenWithDefaultTrueFlag() { + // Only meaningful when the env/.env cascade yields no token: with one present, the client + // wouldn't enter demo mode here, so the test would be exercising the wrong code path. Skip + // rather than fail in dev environments where a real token sits in .env. + Assumptions.assumeTrue( + Configuration.loadFromProcess().resolve(null, "MARKETDATA_TOKEN") == null, + "MARKETDATA_TOKEN present in cascade — can't test demo-mode path here"); + + handler.setResponse(500, "should not be called"); // would fail validation if invoked + + try (var client = + MarketDataClient.builder() + .baseUrl("http://127.0.0.1:" + server.getAddress().getPort()) + .build()) { + assertThat(client.isDemoMode()).isTrue(); + assertThat(handler.callsTo("/user/")).isZero(); + } + } + + @Test + void explicitOptOutSkipsValidation() { + handler.setResponse(500, "should not be called"); + + try (var client = builder().validateOnStartup(false).build()) { + assertThat(client.isValidateOnStartup()).isFalse(); + assertThat(handler.callsTo("/user/")).isZero(); + } + } + + // ---------- helpers ---------- + + private static final class RouteHandler implements HttpHandler { + private final AtomicInteger userCalls = new AtomicInteger(); + private int statusCode = 200; + private String body = "{}"; + private List extraHeaders = List.of(); + + void setResponse(int code, String body) { + setResponse(code, body, List.of()); + } + + void setResponse(int code, String body, List extraHeaders) { + this.statusCode = code; + this.body = body; + this.extraHeaders = extraHeaders; + } + + int callsTo(String path) { + return path.equals("/user/") ? userCalls.get() : 0; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + if ("/user/".equals(exchange.getRequestURI().getPath())) { + userCalls.incrementAndGet(); + } + for (String[] h : extraHeaders) { + exchange.getResponseHeaders().add(h[0], h[1]); + } + byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, bodyBytes.length); + exchange.getResponseBody().write(bodyBytes); + exchange.getResponseBody().close(); + } + } +} diff --git a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java index 8e9cfc8..d9fac5f 100644 --- a/src/test/java/com/marketdata/sdk/MarketDataClientTest.java +++ b/src/test/java/com/marketdata/sdk/MarketDataClientTest.java @@ -7,9 +7,15 @@ class MarketDataClientTest { + // All tests use validateOnStartup(false): with the default (true), the + // client's constructor would call /user/ live, requiring a real token and + // network access. Validation behavior itself is exercised in + // UtilitiesResourceTest + MarketDataClientStartupValidationTest. + @Test void buildsWithExplicitToken() { - try (var client = MarketDataClient.builder().apiKey("test-key").build()) { + try (var client = + MarketDataClient.builder().apiKey("test-key").validateOnStartup(false).build()) { assertThat(client.isDemoMode()).isFalse(); assertThat(client.getBaseUrl()).isEqualTo(Configuration.DEFAULT_BASE_URL); assertThat(client.getApiVersion()).isEqualTo(Configuration.DEFAULT_API_VERSION); @@ -18,14 +24,13 @@ void buildsWithExplicitToken() { @Test void demoModeWhenNoTokenAvailable() { - // No apiKey set on the builder. 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()) { - String envToken = System.getenv("MARKETDATA_TOKEN"); - boolean expectDemo = envToken == null || envToken.isBlank(); - assertThat(client.isDemoMode()).isEqualTo(expectDemo); + // No apiKey set on the builder. Demo mode iff the *full* cascade — env var AND .env file — + // yields no token. We ask Configuration directly for the truth instead of probing only + // System.getenv, otherwise a local .env with a token would silently desync this assertion. + try (var client = MarketDataClient.builder().validateOnStartup(false).build()) { + boolean cascadeHasToken = + Configuration.loadFromProcess().resolve(null, "MARKETDATA_TOKEN") != null; + assertThat(client.isDemoMode()).isEqualTo(!cascadeHasToken); } } @@ -46,14 +51,14 @@ void overridesAreHonored() { @Test void userAgentMatchesSpec() { - try (var client = MarketDataClient.builder().apiKey("KEY").build()) { + try (var client = MarketDataClient.builder().apiKey("KEY").validateOnStartup(false).build()) { assertThat(client.getUserAgent()).startsWith("marketdata-sdk-java/"); } } @Test void rateLimitsStartUnpopulated() { - try (var client = MarketDataClient.builder().apiKey("KEY").build()) { + try (var client = MarketDataClient.builder().apiKey("KEY").validateOnStartup(false).build()) { assertThat(client.getRateLimits()).isNull(); } } diff --git a/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java b/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java index 9ce9f7b..89ca5bd 100644 --- a/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java +++ b/src/test/java/com/marketdata/sdk/exception/MarketDataExceptionTest.java @@ -9,13 +9,13 @@ class MarketDataExceptionTest { @Test void emptyContextLeavesFieldsNull() { - var error = new BadRequestError("symbol must not be blank", ErrorContext.empty()); + var error = new BadRequestException("symbol must not be blank", ErrorContext.empty()); assertThat(error.getRequestId()).isNull(); assertThat(error.getRequestUrl()).isNull(); assertThat(error.getStatusCode()).isNull(); assertThat(error.getTimestamp()).isNotNull(); - assertThat(error.getExceptionType()).isEqualTo("BadRequestError"); + assertThat(error.getExceptionType()).isEqualTo("BadRequestException"); } @Test @@ -24,22 +24,22 @@ void carriesContextFields() { new ErrorContext( "8a1b2c3d4e5f6g7h-SJC", "https://api.marketdata.app/v1/stocks/quotes/AAPL/", 429); - var error = new RateLimitError("Rate limit exceeded", ctx); + var error = new RateLimitException("Rate limit exceeded", ctx); assertThat(error.getRequestId()).isEqualTo("8a1b2c3d4e5f6g7h-SJC"); assertThat(error.getStatusCode()).isEqualTo(429); - assertThat(error.getExceptionType()).isEqualTo("RateLimitError"); + assertThat(error.getExceptionType()).isEqualTo("RateLimitException"); } @Test void supportInfoIncludesAllRequiredFields() { var ctx = new ErrorContext("RAY-1", "https://api.marketdata.app/v1/stocks/quotes/AAPL/", 429); - var error = new RateLimitError("Rate limit exceeded", ctx); + var error = new RateLimitException("Rate limit exceeded", ctx); String supportInfo = error.getSupportInfo(); assertThat(supportInfo) - .contains("RateLimitError") + .contains("RateLimitException") .contains("Rate limit exceeded") .contains("429") .contains("RAY-1") @@ -53,18 +53,18 @@ void allSubtypesCarryContextAndCause() { var cause = new RuntimeException("root cause"); // The four subtypes not exercised by the other tests in this file. - var net = new NetworkError("network down", ctx, cause); - var nf = new NotFoundError("not found", ctx); - var pe = new ParseError("bad json", ctx, cause); - var se = new ServerError("internal", ctx); + var net = new NetworkException("network down", ctx, cause); + var nf = new NotFoundException("not found", ctx); + var pe = new ParseException("bad json", ctx, cause); + var se = new ServerException("internal", ctx); - assertThat(net.getExceptionType()).isEqualTo("NetworkError"); + assertThat(net.getExceptionType()).isEqualTo("NetworkException"); assertThat(net.getCause()).isSameAs(cause); - assertThat(nf.getExceptionType()).isEqualTo("NotFoundError"); + assertThat(nf.getExceptionType()).isEqualTo("NotFoundException"); assertThat(nf.getCause()).isNull(); - assertThat(pe.getExceptionType()).isEqualTo("ParseError"); + assertThat(pe.getExceptionType()).isEqualTo("ParseException"); assertThat(pe.getCause()).isSameAs(cause); - assertThat(se.getExceptionType()).isEqualTo("ServerError"); + assertThat(se.getExceptionType()).isEqualTo("ServerException"); assertThat(se.getCause()).isNull(); for (MarketDataException ex : List.of(net, nf, pe, se)) { @@ -84,13 +84,13 @@ void everySubtypeExposesBothConstructors() { // Exercise the one that the other tests in this file don't already hit. List exhaustive = List.of( - new AuthenticationError("a", ctx, cause), - new BadRequestError("b", ctx, cause), - new NotFoundError("n", ctx, cause), - new RateLimitError("r", ctx, cause), - new ServerError("s", ctx, cause), - new NetworkError("net", ctx), // cause-less variant - new ParseError("p", ctx)); // cause-less variant + new AuthenticationException("a", ctx, cause), + new BadRequestException("b", ctx, cause), + new NotFoundException("n", ctx, cause), + new RateLimitException("r", ctx, cause), + new ServerException("s", ctx, cause), + new NetworkException("net", ctx), // cause-less variant + new ParseException("p", ctx)); // cause-less variant for (MarketDataException ex : exhaustive) { assertThat(ex.getMessage()).isNotBlank(); @@ -98,12 +98,31 @@ void everySubtypeExposesBothConstructors() { } } + @Test + void supportInfoRendersNAForMissingFields() { + // Counterpart to supportInfoIncludesAllRequiredFields: this exercises the "(n/a)" + // branch of each ternary in getSupportInfo, which the other tests skip because they + // always pass a fully-populated ErrorContext. Together they bring branch coverage of + // getSupportInfo from 50% to 100%. + var error = new BadRequestException("symbol must not be blank", ErrorContext.empty()); + + String supportInfo = error.getSupportInfo(); + + assertThat(supportInfo) + .contains("Type: BadRequestException") + .contains("Message: symbol must not be blank") + .contains("Status code: (n/a)") + .contains("Request ID: (n/a)") + .contains("Request URL: (n/a)") + .contains("US/Eastern"); + } + @Test void supportInfoNeverContainsSensitiveData() { // The exception itself never receives the token; we just // double-check that the canonical message+URL form doesn't leak. - var ctx = new ErrorContext("RAY-1", "https://api.marketdata.app/v1/user/", 401); - var error = new AuthenticationError("Invalid token", ctx); + var ctx = new ErrorContext("RAY-1", "https://api.marketdata.app/user/", 401); + var error = new AuthenticationException("Invalid token", ctx); assertThat(error.getSupportInfo()).doesNotContain("token=").doesNotContain("Bearer "); } diff --git a/src/test/java/com/marketdata/sdk/internal/wire/markets/MarketStatusDeserializerTest.java b/src/test/java/com/marketdata/sdk/internal/wire/markets/MarketStatusDeserializerTest.java new file mode 100644 index 0000000..bbb51ea --- /dev/null +++ b/src/test/java/com/marketdata/sdk/internal/wire/markets/MarketStatusDeserializerTest.java @@ -0,0 +1,76 @@ +package com.marketdata.sdk.internal.wire.markets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.marketdata.sdk.markets.MarketStatus; +import java.io.IOException; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; + +class MarketStatusDeserializerTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void parsesOkResponseIntoChronologicalDays() throws IOException { + // 1706745600 = 2024-02-01 00:00:00 UTC = 2024-01-31 19:00 US/Eastern → date 2024-01-31 + // The API normalizes "trading day midnight Eastern" to a unix timestamp; we expect the + // deserializer to recover the local Eastern date. + String json = + """ + { "s": "ok", + "date": [1706673600, 1706760000, 1706846400], + "status": ["open", "open", "closed"] } + """; + + MarketStatus status = mapper.readValue(json, MarketStatus.class); + + assertThat(status.days()).hasSize(3); + assertThat(status.days().get(0).open()).isTrue(); + assertThat(status.days().get(1).open()).isTrue(); + assertThat(status.days().get(2).open()).isFalse(); + assertThat(status.days().get(0).date()).isInstanceOf(LocalDate.class); + assertThat(status.isEmpty()).isFalse(); + } + + @Test + void noDataResponseProducesEmptyResult() throws IOException { + MarketStatus status = mapper.readValue("{\"s\":\"no_data\"}", MarketStatus.class); + + assertThat(status.days()).isEmpty(); + assertThat(status.isEmpty()).isTrue(); + } + + @Test + void rejectsUnknownStatusField() { + assertThatThrownBy(() -> mapper.readValue("{\"s\":\"weird\"}", MarketStatus.class)) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("'weird'"); + } + + @Test + void rejectsMismatchedArraySizes() { + String json = + """ + { "s": "ok", + "date": [1706673600, 1706760000], + "status": ["open"] } + """; + + assertThatThrownBy(() -> mapper.readValue(json, MarketStatus.class)) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("different sizes"); + } + + @Test + void rejectsResponseMissingArrays() { + String json = "{\"s\":\"ok\"}"; + + assertThatThrownBy(() -> mapper.readValue(json, MarketStatus.class)) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("expected 'date' and 'status' arrays"); + } +} diff --git a/src/test/java/com/marketdata/sdk/internal/wire/utilities/RequestHeadersDeserializerTest.java b/src/test/java/com/marketdata/sdk/internal/wire/utilities/RequestHeadersDeserializerTest.java new file mode 100644 index 0000000..12c96c9 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/internal/wire/utilities/RequestHeadersDeserializerTest.java @@ -0,0 +1,51 @@ +package com.marketdata.sdk.internal.wire.utilities; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.marketdata.sdk.utilities.RequestHeaders; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class RequestHeadersDeserializerTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void parsesArbitraryHeadersAndLowercasesKeys() throws IOException { + String json = + """ + { + "accept": "*/*", + "Authorization": "Bearer ***YKT0", + "X-Real-IP": "127.0.0.1", + "User-Agent": "marketdata-sdk-java/0.1.0" + } + """; + + RequestHeaders result = mapper.readValue(json, RequestHeaders.class); + + assertThat(result.all()).hasSize(4); + // case-insensitive lookup roundtrips: + assertThat(result.get("Authorization")).hasValue("Bearer ***YKT0"); + assertThat(result.get("authorization")).hasValue("Bearer ***YKT0"); + assertThat(result.get("AUTHORIZATION")).hasValue("Bearer ***YKT0"); + assertThat(result.get("user-agent")).hasValue("marketdata-sdk-java/0.1.0"); + } + + @Test + void emptyObjectProducesEmptyResult() throws IOException { + RequestHeaders result = mapper.readValue("{}", RequestHeaders.class); + assertThat(result.isEmpty()).isTrue(); + assertThat(result.get("anything")).isEmpty(); + } + + @Test + void nonObjectResponseProducesParseException() { + assertThatThrownBy(() -> mapper.readValue("[]", RequestHeaders.class)) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("expected a JSON object"); + } +} diff --git a/src/test/java/com/marketdata/sdk/internal/wire/utilities/ServiceStatusDeserializerTest.java b/src/test/java/com/marketdata/sdk/internal/wire/utilities/ServiceStatusDeserializerTest.java new file mode 100644 index 0000000..b5b1d9e --- /dev/null +++ b/src/test/java/com/marketdata/sdk/internal/wire/utilities/ServiceStatusDeserializerTest.java @@ -0,0 +1,87 @@ +package com.marketdata.sdk.internal.wire.utilities; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.marketdata.sdk.utilities.ServiceStatus; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class ServiceStatusDeserializerTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void parsesOkResponseIntoChronologicalServices() throws IOException { + String json = + """ + { "s":"ok", + "service": ["/v1/funds/candles/", "/v1/stocks/quotes/"], + "status": ["online", "offline"], + "online": [true, false], + "uptimePct30d": [1.0, 0.998], + "uptimePct90d": [1.0, 0.997], + "updated": [1734036832, 1734036832] } + """; + + ServiceStatus result = mapper.readValue(json, ServiceStatus.class); + + assertThat(result.services()).hasSize(2); + assertThat(result.services().get(0).service()).isEqualTo("/v1/funds/candles/"); + assertThat(result.services().get(0).online()).isTrue(); + assertThat(result.services().get(1).status()).isEqualTo("offline"); + assertThat(result.services().get(1).online()).isFalse(); + assertThat(result.services().get(1).uptimePct30d()).isEqualTo(0.998); + assertThat(result.allOnline()).isFalse(); + } + + @Test + void allOnlineIsTrueOnlyWhenEveryServiceIsOnline() throws IOException { + String json = + """ + { "s":"ok", + "service": ["a", "b"], + "status": ["online", "online"], + "online": [true, true], + "uptimePct30d": [1, 1], + "uptimePct90d": [1, 1], + "updated": [1, 1] } + """; + + assertThat(mapper.readValue(json, ServiceStatus.class).allOnline()).isTrue(); + } + + @Test + void noDataResponseProducesEmpty() throws IOException { + ServiceStatus result = mapper.readValue("{\"s\":\"no_data\"}", ServiceStatus.class); + assertThat(result.isEmpty()).isTrue(); + assertThat(result.allOnline()).isFalse(); // empty is not "all online" + } + + @Test + void mismatchedArraySizesProduceParseException() { + String json = + """ + { "s":"ok", + "service": ["a", "b"], + "status": ["online"], + "online": [true, true], + "uptimePct30d": [1, 1], + "uptimePct90d": [1, 1], + "updated": [1, 1] } + """; + + assertThatThrownBy(() -> mapper.readValue(json, ServiceStatus.class)) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("don't match"); + } + + @Test + void unknownStatusFieldProducesParseException() { + assertThatThrownBy(() -> mapper.readValue("{\"s\":\"weird\"}", ServiceStatus.class)) + .isInstanceOf(JsonMappingException.class) + .hasMessageContaining("'weird'"); + } +} diff --git a/src/test/java/com/marketdata/sdk/markets/CallMode.java b/src/test/java/com/marketdata/sdk/markets/CallMode.java new file mode 100644 index 0000000..62e21ac --- /dev/null +++ b/src/test/java/com/marketdata/sdk/markets/CallMode.java @@ -0,0 +1,67 @@ +package com.marketdata.sdk.markets; + +import java.time.LocalDate; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +/** + * Drives a {@code /v1/markets/*} call through either the sync or async surface. ASYNC mode unwraps + * {@link CompletionException} so caller-visible behavior matches sync (per ADR-006: sync wraps + * {@code .join()} and surfaces the underlying cause directly). + * + *

Lives in the unit-test source set so it is reusable from the integration-test source set — + * `integrationTest`'s compileClasspath includes the unit-test output (see {@code + * build.gradle.kts}). Package-private intentionally: only test classes in {@code + * com.marketdata.sdk.markets} need it. + */ +enum CallMode { + SYNC { + @Override + MarketStatus statusNoArgs(MarketsResource r) { + return r.status(); + } + + @Override + MarketStatus statusForDate(MarketsResource r, LocalDate date) { + return r.status(date); + } + + @Override + MarketStatus statusForRange(MarketsResource r, LocalDate from, LocalDate to) { + return r.status(from, to); + } + }, + ASYNC { + @Override + MarketStatus statusNoArgs(MarketsResource r) { + return joinUnwrapping(r.statusAsync()); + } + + @Override + MarketStatus statusForDate(MarketsResource r, LocalDate date) { + return joinUnwrapping(r.statusAsync(date)); + } + + @Override + MarketStatus statusForRange(MarketsResource r, LocalDate from, LocalDate to) { + return joinUnwrapping(r.statusAsync(from, to)); + } + }; + + abstract MarketStatus statusNoArgs(MarketsResource r); + + abstract MarketStatus statusForDate(MarketsResource r, LocalDate date); + + abstract MarketStatus statusForRange(MarketsResource r, LocalDate from, LocalDate to); + + private static T joinUnwrapping(CompletableFuture future) { + try { + return future.join(); + } catch (CompletionException e) { + if (e.getCause() instanceof RuntimeException re) { + throw re; + } + throw e; + } + } +} diff --git a/src/test/java/com/marketdata/sdk/markets/MarketsResourceTest.java b/src/test/java/com/marketdata/sdk/markets/MarketsResourceTest.java new file mode 100644 index 0000000..e0fcbae --- /dev/null +++ b/src/test/java/com/marketdata/sdk/markets/MarketsResourceTest.java @@ -0,0 +1,421 @@ +package com.marketdata.sdk.markets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.RateLimits; +import com.marketdata.sdk.exception.AuthenticationException; +import com.marketdata.sdk.exception.NetworkException; +import com.marketdata.sdk.exception.ParseException; +import com.marketdata.sdk.exception.RateLimitException; +import com.marketdata.sdk.exception.ServerException; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +/** + * Exercises the full resource → transport → HTTP path against an in-process {@link HttpServer} (JDK + * built-in — no extra mock dep). Verifies URL construction, query-param encoding, response + * decoding, error mapping, and rate-limit header parsing. + */ +class MarketsResourceTest { + + private HttpServer server; + private final AtomicReference lastRequest = new AtomicReference<>(); + private RouteHandler handler; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + handler = new RouteHandler(); + server.createContext("/", handler); + server.start(); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + private MarketDataClient newClient() { + int port = server.getAddress().getPort(); + return MarketDataClient.builder() + .apiKey("test-key") + .baseUrl("http://127.0.0.1:" + port) + .validateOnStartup(false) + .build(); + } + + // ---------- success paths ---------- + + /** + * The 5 paths exercised below are the load-bearing scenarios — each runs once for {@link + * CallMode#SYNC} and once for {@link CallMode#ASYNC} so we satisfy SDK requirements §13's "tests + * must cover both sync and async variants for every endpoint" without duplicating every single + * mechanical case. + */ + @ParameterizedTest + @EnumSource(CallMode.class) + void statusNoArgsHitsCanonicalUrlAndDecodesPayload(CallMode mode) { + handler.setResponse( + 200, + """ + { "s":"ok", "date":[1706673600,1706760000], "status":["open","closed"] } + """, + List.of( + rateLimitHeader("limit", "50000"), + rateLimitHeader("remaining", "49500"), + rateLimitHeader("reset", "1735689600"), + rateLimitHeader("consumed", "1"))); + + try (var client = newClient()) { + MarketStatus result = mode.statusNoArgs(client.markets()); + + assertThat(result.days()).hasSize(2); + assertThat(result.days().get(0).open()).isTrue(); + assertThat(result.days().get(1).open()).isFalse(); + + RecordedRequest req = lastRequest.get(); + assertThat(req.path).isEqualTo("/v1/markets/status/"); + assertThat(req.query).isNull(); + assertThat(req.headers.firstValue("Authorization")).hasValue("Bearer test-key"); + assertThat(req.headers.firstValue("User-Agent")) + .get() + .asString() + .startsWith("marketdata-sdk-java/"); + assertThat(req.headers.firstValue("Accept")).hasValue("application/json"); + + RateLimits rl = client.getRateLimits(); + assertThat(rl).isNotNull(); + assertThat(rl.limit()).isEqualTo(50000L); + assertThat(rl.remaining()).isEqualTo(49500L); + assertThat(rl.consumed()).isEqualTo(1L); + } + } + + @ParameterizedTest + @EnumSource(CallMode.class) + void statusForDateBuildsDateQueryParam(CallMode mode) { + handler.setResponse( + 200, "{\"s\":\"ok\",\"date\":[1706760000],\"status\":[\"open\"]}", List.of()); + + try (var client = newClient()) { + MarketStatus result = mode.statusForDate(client.markets(), LocalDate.of(2024, 2, 1)); + + assertThat(result.days()).hasSize(1); + assertThat(lastRequest.get().path).isEqualTo("/v1/markets/status/"); + assertThat(lastRequest.get().query).isEqualTo("date=2024-02-01"); + } + } + + @ParameterizedTest + @EnumSource(CallMode.class) + void statusForRangeBuildsFromAndToQueryParams(CallMode mode) { + handler.setResponse( + 200, "{\"s\":\"ok\",\"date\":[1706673600],\"status\":[\"open\"]}", List.of()); + + try (var client = newClient()) { + mode.statusForRange(client.markets(), LocalDate.of(2024, 1, 31), LocalDate.of(2024, 2, 5)); + + assertThat(lastRequest.get().query).isEqualTo("from=2024-01-31&to=2024-02-05"); + } + } + + @Test + void rangeWithSwappedBoundsThrowsIllegalArgument() { + try (var client = newClient()) { + assertThatThrownBy( + () -> client.markets().status(LocalDate.of(2024, 2, 5), LocalDate.of(2024, 1, 31))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not be after"); + } + } + + // ---------- async-specific smoke ---------- + + /** + * Verifies that {@code statusAsync()} returns a real {@link + * java.util.concurrent.CompletableFuture} usable with the standard {@code .get()} contract + * (checked exception path). The {@code @ParameterizedTest}s above cover .join() semantics; this + * one covers .get(). + */ + @Test + void statusAsyncReturnsRealCompletableFuture() throws Exception { + handler.setResponse( + 200, "{\"s\":\"ok\",\"date\":[1706760000],\"status\":[\"closed\"]}", List.of()); + + try (var client = newClient()) { + MarketStatus async = client.markets().statusAsync().get(); + assertThat(async.days()).hasSize(1); + assertThat(async.days().get(0).open()).isFalse(); + } + } + + // ---------- no-data and error paths ---------- + + @ParameterizedTest + @EnumSource(CallMode.class) + void notFoundWithNoDataBodyDecodesAsEmpty(CallMode mode) { + handler.setResponse(404, "{\"s\":\"no_data\"}", List.of()); + + try (var client = newClient()) { + MarketStatus result = mode.statusNoArgs(client.markets()); + assertThat(result.isEmpty()).isTrue(); + } + } + + @ParameterizedTest + @EnumSource(CallMode.class) + void http401ThrowsAuthenticationException(CallMode mode) { + handler.setResponse(401, "{}", List.of()); + + try (var client = newClient()) { + assertThatThrownBy(() -> mode.statusNoArgs(client.markets())) + .isInstanceOf(AuthenticationException.class) + .satisfies( + t -> { + AuthenticationException ae = (AuthenticationException) t; + assertThat(ae.getStatusCode()).isEqualTo(401); + assertThat(ae.getRequestUrl()).contains("/v1/markets/status/"); + }); + } + } + + @Test + void http429ThrowsRateLimitException() { + handler.setResponse(429, "{}", List.of()); + + try (var client = newClient()) { + assertThatThrownBy(() -> client.markets().status()).isInstanceOf(RateLimitException.class); + } + } + + @Test + void http500ThrowsServerException() { + handler.setResponse(500, "{}", List.of()); + + try (var client = newClient()) { + assertThatThrownBy(() -> client.markets().status()).isInstanceOf(ServerException.class); + } + } + + // ---------- malformed responses ---------- + + @ParameterizedTest + @EnumSource(CallMode.class) + void garbageBodyOnSuccessProducesParseException(CallMode mode) { + handler.setResponse(200, "this is plainly not json", List.of()); + + try (var client = newClient()) { + assertThatThrownBy(() -> mode.statusNoArgs(client.markets())) + .isInstanceOf(ParseException.class) + .hasMessageContaining("Failed to decode"); + } + } + + @Test + void emptyBodyOnSuccessProducesParseException() { + handler.setResponse(200, "", List.of()); + + try (var client = newClient()) { + assertThatThrownBy(() -> client.markets().status()).isInstanceOf(ParseException.class); + } + } + + @Test + void unknownStatusFieldProducesParseException() { + handler.setResponse(200, "{\"s\":\"weird\"}", List.of()); + + try (var client = newClient()) { + assertThatThrownBy(() -> client.markets().status()) + .isInstanceOf(ParseException.class) + .hasMessageContaining("weird"); + } + } + + @Test + void responseMissingArraysProducesParseException() { + handler.setResponse(200, "{\"s\":\"ok\"}", List.of()); + + try (var client = newClient()) { + assertThatThrownBy(() -> client.markets().status()) + .isInstanceOf(ParseException.class) + .hasMessageContaining("date"); + } + } + + @Test + void mismatchedArraySizesProduceParseException() { + handler.setResponse( + 200, "{\"s\":\"ok\",\"date\":[1706673600,1706760000],\"status\":[\"open\"]}", List.of()); + + try (var client = newClient()) { + assertThatThrownBy(() -> client.markets().status()) + .isInstanceOf(ParseException.class) + .hasMessageContaining("different sizes"); + } + } + + // ---------- weird headers ---------- + + @Test + void successWithoutAnyRateLimitHeadersLeavesSnapshotNull() { + handler.setResponse( + 200, "{\"s\":\"ok\",\"date\":[1706673600],\"status\":[\"open\"]}", List.of()); + + try (var client = newClient()) { + client.markets().status(); + assertThat(client.getRateLimits()).isNull(); + } + } + + @Test + void partialRateLimitHeadersStillProduceSnapshot() { + handler.setResponse( + 200, + "{\"s\":\"ok\",\"date\":[1706673600],\"status\":[\"open\"]}", + List.of( + new String[] {"x-api-ratelimit-limit", "100000"}, + new String[] {"x-api-ratelimit-remaining", "99999"})); + + try (var client = newClient()) { + client.markets().status(); + + RateLimits rl = client.getRateLimits(); + assertThat(rl).isNotNull(); + assertThat(rl.limit()).isEqualTo(100_000L); + assertThat(rl.remaining()).isEqualTo(99_999L); + assertThat(rl.consumed()).isEqualTo(0L); // missing → defaulted + } + } + + @Test + void allUnparseableRateLimitHeadersAreIgnoredAsAbsent() { + handler.setResponse( + 200, + "{\"s\":\"ok\",\"date\":[1706673600],\"status\":[\"open\"]}", + List.of( + new String[] {"x-api-ratelimit-limit", "not-a-number"}, + new String[] {"x-api-ratelimit-remaining", "still-not"}, + new String[] {"x-api-ratelimit-reset", "??"}, + new String[] {"x-api-ratelimit-consumed", "wat"})); + + try (var client = newClient()) { + client.markets().status(); + assertThat(client.getRateLimits()).isNull(); + } + } + + @Test + void errorResponseWithoutCfRayProducesNullRequestId() { + handler.setResponse(401, "{}", List.of()); + + try (var client = newClient()) { + assertThatThrownBy(() -> client.markets().status()) + .isInstanceOf(AuthenticationException.class) + .satisfies(t -> assertThat(((AuthenticationException) t).getRequestId()).isNull()); + } + } + + @Test + void errorResponseWithCfRayPropagatesRequestId() { + handler.setResponse(401, "{}", List.of(new String[] {"cf-ray", "abc123-XYZ"})); + + try (var client = newClient()) { + assertThatThrownBy(() -> client.markets().status()) + .isInstanceOf(AuthenticationException.class) + .satisfies( + t -> + assertThat(((AuthenticationException) t).getRequestId()).isEqualTo("abc123-XYZ")); + } + } + + // ---------- network failure (connect refused — fast-failing proxy for timeout class) ---------- + + /** + * The 99-second per-request timeout is fixed by SDK requirements §10. Forcing a real timeout in a + * test would block for ~99 s, which we don't want. Instead we exercise the {@link + * NetworkException} path by pointing the client at a port nothing is listening on (TCP RST → fast + * failure). This proves the transport surfaces transport-level failures as a typed exception + * rather than letting raw {@code IOException}s leak. + */ + @ParameterizedTest + @EnumSource(CallMode.class) + void connectionRefusedProducesNetworkException(CallMode mode) { + try (var client = + MarketDataClient.builder() + .apiKey("test-key") + .baseUrl("http://127.0.0.1:1") // port 1 is privileged and rejects fast. + .validateOnStartup(false) + .build()) { + + assertThatThrownBy(() -> mode.statusNoArgs(client.markets())) + .isInstanceOf(NetworkException.class) + .satisfies( + t -> { + NetworkException ne = (NetworkException) t; + assertThat(ne.getCause()).isNotNull(); + assertThat(ne.getRequestUrl()).contains("127.0.0.1:1"); + }); + } + } + + // ---------- helpers ---------- + + // CallMode (sync vs async dispatcher) lives in its own file so the integration-test source set + // can reuse it. See CallMode.java in this same package. + + private static String[] rateLimitHeader(String suffix, String value) { + return new String[] {"x-api-ratelimit-" + suffix, value}; + } + + private record RecordedRequest(String path, String query, java.net.http.HttpHeaders headers) {} + + private final class RouteHandler implements HttpHandler { + private int statusCode = 200; + private String body = "{}"; + private List extraHeaders = List.of(); + + void setResponse(int code, String body, List extraHeaders) { + this.statusCode = code; + this.body = body; + this.extraHeaders = extraHeaders; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + // Snapshot request shape for assertions. + URI uri = exchange.getRequestURI(); + var headerMap = new java.util.HashMap>(); + exchange.getRequestHeaders().forEach((k, v) -> headerMap.put(k, new ArrayList<>(v))); + lastRequest.set( + new RecordedRequest( + uri.getPath(), + uri.getRawQuery(), + java.net.http.HttpHeaders.of(headerMap, (a, b) -> true))); + + for (String[] h : extraHeaders) { + exchange.getResponseHeaders().add(h[0], h[1]); + } + byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, bodyBytes.length); + exchange.getResponseBody().write(bodyBytes); + exchange.getResponseBody().close(); + } + } +} diff --git a/src/test/java/com/marketdata/sdk/utilities/UtilitiesResourceTest.java b/src/test/java/com/marketdata/sdk/utilities/UtilitiesResourceTest.java new file mode 100644 index 0000000..47ca470 --- /dev/null +++ b/src/test/java/com/marketdata/sdk/utilities/UtilitiesResourceTest.java @@ -0,0 +1,280 @@ +package com.marketdata.sdk.utilities; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.marketdata.sdk.MarketDataClient; +import com.marketdata.sdk.RateLimits; +import com.marketdata.sdk.exception.AuthenticationException; +import com.marketdata.sdk.exception.ParseException; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +/** + * Same shape as {@code MarketsResourceTest}: full resource → transport → in-process {@link + * HttpServer}, parameterized over sync + async to satisfy SDK requirements §13. + */ +class UtilitiesResourceTest { + + private HttpServer server; + private final AtomicReference lastRequest = new AtomicReference<>(); + private RouteHandler handler; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + handler = new RouteHandler(); + server.createContext("/", handler); + server.start(); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + private MarketDataClient newClient() { + int port = server.getAddress().getPort(); + return MarketDataClient.builder() + .apiKey("test-key") + .baseUrl("http://127.0.0.1:" + port) + .validateOnStartup(false) + .build(); + } + + // ---------- success path (sync + async) ---------- + + @ParameterizedTest + @EnumSource(CallMode.class) + void userHitsCanonicalUrlAndDecodesPayload(CallMode mode) { + handler.setResponse( + 200, + "{\"x-ratelimit-requests-remaining\":49500," + + "\"x-ratelimit-requests-limit\":50000," + + "\"x-options-data-permissions\":\"\"}", + List.of( + new String[] {"x-api-ratelimit-limit", "50000"}, + new String[] {"x-api-ratelimit-remaining", "49500"}, + new String[] {"x-api-ratelimit-reset", "1735689600"}, + new String[] {"x-api-ratelimit-consumed", "1"})); + + try (var client = newClient()) { + UserInfo info = mode.user(client.utilities()); + + assertThat(info.requestsLimit()).isEqualTo(50_000L); + assertThat(info.requestsRemaining()).isEqualTo(49_500L); + assertThat(info.optionsDataPermissions()).isEmpty(); + + RecordedRequest req = lastRequest.get(); + assertThat(req.path).isEqualTo("/user/"); + assertThat(req.headers.firstValue("Authorization")).hasValue("Bearer test-key"); + + // §8.1 side-effect: rate-limit headers populate the client snapshot. + RateLimits rl = client.getRateLimits(); + assertThat(rl).isNotNull(); + assertThat(rl.limit()).isEqualTo(50_000L); + assertThat(rl.remaining()).isEqualTo(49_500L); + } + } + + @ParameterizedTest + @EnumSource(CallMode.class) + void delayedOptionsPermissionsAreRoundtripped(CallMode mode) { + handler.setResponse( + 200, + "{\"x-ratelimit-requests-remaining\":100," + + "\"x-ratelimit-requests-limit\":1000," + + "\"x-options-data-permissions\":\"OPRA data delayed 15 minutes\"}", + List.of()); + + try (var client = newClient()) { + UserInfo info = mode.user(client.utilities()); + + assertThat(info.optionsDataPermissions()).isEqualTo("OPRA data delayed 15 minutes"); + } + } + + // ---------- /status/ (root path, no /v1/ prefix) ---------- + + @Test + void statusHitsRootPathNotV1() { + handler.setResponse( + 200, + "{\"s\":\"ok\"," + + "\"service\":[\"/v1/stocks/quotes/\"]," + + "\"status\":[\"online\"]," + + "\"online\":[true]," + + "\"uptimePct30d\":[1.0]," + + "\"uptimePct90d\":[1.0]," + + "\"updated\":[1734036832]}", + List.of()); + + try (var client = newClient()) { + ServiceStatus result = client.utilities().status(); + + assertThat(result.services()).hasSize(1); + assertThat(result.allOnline()).isTrue(); + // Critical: NO /v1/ prefix on the URL. + assertThat(lastRequest.get().path).isEqualTo("/status/"); + } + } + + @Test + void statusAsyncHitsSamePath() throws Exception { + handler.setResponse( + 200, + "{\"s\":\"ok\"," + + "\"service\":[\"/v1/funds/candles/\"]," + + "\"status\":[\"online\"]," + + "\"online\":[true]," + + "\"uptimePct30d\":[1.0]," + + "\"uptimePct90d\":[1.0]," + + "\"updated\":[1]}", + List.of()); + + try (var client = newClient()) { + ServiceStatus result = client.utilities().statusAsync().get(); + assertThat(result.services()).hasSize(1); + assertThat(lastRequest.get().path).isEqualTo("/status/"); + } + } + + // ---------- /headers/ (root path, no /v1/ prefix) ---------- + + @Test + void headersHitsRootPathAndDecodesArbitraryKeys() { + handler.setResponse( + 200, + "{" + + "\"accept\":\"*/*\"," + + "\"Authorization\":\"Bearer ***YKT0\"," + + "\"User-Agent\":\"marketdata-sdk-java/0.1.0-SNAPSHOT\"" + + "}", + List.of()); + + try (var client = newClient()) { + RequestHeaders result = client.utilities().headers(); + + assertThat(result.get("Authorization")).hasValue("Bearer ***YKT0"); + assertThat(result.get("user-agent")).get().asString().contains("marketdata-sdk-java"); + assertThat(lastRequest.get().path).isEqualTo("/headers/"); + } + } + + @Test + void headersAsyncReturnsRealCompletableFuture() throws Exception { + handler.setResponse(200, "{\"x-test\":\"yes\"}", List.of()); + + try (var client = newClient()) { + RequestHeaders result = client.utilities().headersAsync().get(); + assertThat(result.get("x-test")).hasValue("yes"); + } + } + + // ---------- error paths (sync + async) ---------- + + @ParameterizedTest + @EnumSource(CallMode.class) + void http401ThrowsAuthenticationException(CallMode mode) { + handler.setResponse(401, "{}", List.of()); + + try (var client = newClient()) { + assertThatThrownBy(() -> mode.user(client.utilities())) + .isInstanceOf(AuthenticationException.class) + .satisfies( + t -> { + AuthenticationException ae = (AuthenticationException) t; + assertThat(ae.getStatusCode()).isEqualTo(401); + assertThat(ae.getRequestUrl()).contains("/user/"); + }); + } + } + + @Test + void garbageBodyOnSuccessProducesParseException() { + handler.setResponse(200, "this is not json", List.of()); + + try (var client = newClient()) { + assertThatThrownBy(() -> client.utilities().user()) + .isInstanceOf(ParseException.class) + .hasMessageContaining("Failed to decode"); + } + } + + // ---------- helpers ---------- + + /** + * Local sync/async dispatcher. Lives here instead of reusing the markets-package {@code CallMode} + * because the call signatures differ per resource — utilities only has {@code user()}, markets + * has three overloads. + */ + enum CallMode { + SYNC { + @Override + UserInfo user(UtilitiesResource r) { + return r.user(); + } + }, + ASYNC { + @Override + UserInfo user(UtilitiesResource r) { + try { + return r.userAsync().join(); + } catch (CompletionException e) { + if (e.getCause() instanceof RuntimeException re) { + throw re; + } + throw e; + } + } + }; + + abstract UserInfo user(UtilitiesResource r); + } + + private record RecordedRequest(String path, java.net.http.HttpHeaders headers) {} + + private final class RouteHandler implements HttpHandler { + private int statusCode = 200; + private String body = "{}"; + private List extraHeaders = List.of(); + + void setResponse(int code, String body, List extraHeaders) { + this.statusCode = code; + this.body = body; + this.extraHeaders = extraHeaders; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + var headerMap = new java.util.HashMap>(); + exchange.getRequestHeaders().forEach((k, v) -> headerMap.put(k, new ArrayList<>(v))); + lastRequest.set( + new RecordedRequest( + exchange.getRequestURI().getPath(), + java.net.http.HttpHeaders.of(headerMap, (a, b) -> true))); + + for (String[] h : extraHeaders) { + exchange.getResponseHeaders().add(h[0], h[1]); + } + byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, bodyBytes.length); + exchange.getResponseBody().write(bodyBytes); + exchange.getResponseBody().close(); + } + } +}