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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,31 @@ tasks.register<JacocoReport>("jacocoAggregateReport") {
}
}

// Coverage ratchet (line coverage cannot drop more than 5 pp below
// main's last value) is enforced in CI — see .github/workflows/pull-request.yml
// and .github/scripts/check-coverage-delta.py. Not enforced locally so that
// dev iteration isn't blocked while coverage is in flux.
// Coverage ratchet (line coverage cannot drop more than 5 pp below main's last value) is enforced
// in CI — see .github/workflows/pull-request.yml and .github/scripts/check-coverage-delta.py.
//
// SDK requirements §15.3 mandates 100% line coverage with explicit ignore
// comments on untestable lines. Target deferred until business resources land
// and the defensive-guards cleanup pass can run together.
// SDK requirements §15.3: 100% line coverage. Enforced locally and in CI by
// jacocoTestCoverageVerification (wired into `check`). The handful of genuinely untestable lines —
// network-only constructor paths, fail-safe catch blocks, TOCTOU retry edges — are isolated into
// members annotated @Generated, which JaCoCo excludes from the count (the annotation's simple name
// contains "Generated", the marker JaCoCo recognizes since 0.8.2). Each use carries a comment
// explaining why the member is unreachable from a hermetic unit test.
tasks.jacocoTestCoverageVerification {
dependsOn(tasks.test)
violationRules {
rule {
limit {
counter = "LINE"
value = "COVEREDRATIO"
minimum = "1.0".toBigDecimal()
}
}
}
}

tasks.named("check") {
dependsOn(tasks.jacocoTestCoverageVerification)
}

spotless {
java {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ void tearDown() {
}
}

@Test
void csvCandlesReturnsRawCsvText() {
CsvResponse resp =
client
.funds()
.asCsv()
.candles(
FundCandlesRequest.builder(FundResolution.DAILY, SYMBOL)
.from(LocalDate.now().minusMonths(1))
.to(LocalDate.now())
.build());

assertThat(resp.statusCode()).isIn(200, 203);
assertThat(resp.isCsv()).isTrue();
assertThat(resp.csv()).as("CSV facet returns comma-delimited text").isNotBlank().contains(",");
}

@Test
void candlesReturnsDailyOhlcSeries() {
FundCandlesResponse resp =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ void tearDown() {
}
}

@Test
void csvStatusReturnsRawCsvText() {
CsvResponse resp =
client
.markets()
.asCsv()
.status(
MarketStatusRequest.builder()
.from(LocalDate.now().minusDays(7))
.to(LocalDate.now())
.build());

assertThat(resp.statusCode()).isIn(200, 203);
assertThat(resp.isCsv()).isTrue();
assertThat(resp.csv()).as("CSV facet returns comma-delimited text").isNotBlank().contains(",");
}

@Test
void statusReturnsOneRowPerDayInRange() {
MarketStatusResponse resp =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ void tearDown() {
}
}

@Test
void csvExpirationsReturnsRawCsvText() {
CsvResponse resp =
client.options().asCsv().expirations(OptionsExpirationsRequest.of(UNDERLYING));

assertThat(resp.statusCode()).isIn(200, 203);
assertThat(resp.isCsv()).isTrue();
// expirations CSV is a single date column, so assert non-blank text rather than a delimiter.
assertThat(resp.csv()).isNotBlank();
}

@Test
void lookupConvertsHumanDescriptionToOccSymbol() {
// A far-future date keeps the test stable against expiration drift — the endpoint converts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ void tearDown() {
}
}

@Test
void csvCandlesReturnsRawCsvText() {
CsvResponse resp =
client
.stocks()
.asCsv()
.candles(
StockCandlesRequest.builder(StockResolution.DAILY, SYMBOL)
.from(LocalDate.now().minusMonths(1))
.to(LocalDate.now())
.build());

assertThat(resp.statusCode()).isIn(200, 203);
assertThat(resp.isCsv()).isTrue();
assertThat(resp.csv()).as("CSV facet returns comma-delimited text").isNotBlank().contains(",");
}

@Test
void candlesReturnsDailyOhlcvSeries() {
StockCandlesResponse resp =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.marketdata.sdk;

import static org.assertj.core.api.Assertions.assertThat;

import com.marketdata.sdk.utilities.ServiceStatus;
import com.marketdata.sdk.utilities.User;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;

/**
* Integration tests for the {@code utilities} resource against the live Market Data API. Gated by
* the {@code MARKETDATA_RUN_INTEGRATION_TESTS=true} environment variable in {@code
* build.gradle.kts}; a valid {@code MARKETDATA_TOKEN} is also required.
*
* <p>These are the unversioned diagnostic endpoints ({@code /status/}, {@code /headers/}, {@code
* /user/}). Tests assert <strong>shape</strong> rather than specific values: the service list, the
* echoed header map, and the quota record all drift. Status is asserted as {@code 200 || 203} (203
* = cached/delayed data, which the SDK surfaces as success), matching the other resource suites.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UtilitiesIntegrationTest {

private MarketDataClient client;

@BeforeAll
void setUp() {
client = new MarketDataClient();
}

@AfterAll
void tearDown() {
if (client != null) {
client.close();
}
}

@Test
void statusReturnsPerServiceHealth() {
UtilitiesStatusResponse resp = client.utilities().status();

assertThat(resp.statusCode()).isIn(200, 203);
assertThat(resp.values()).as("the API always reports at least one service").isNotEmpty();
ServiceStatus first = resp.values().get(0);
assertThat(first.service()).isNotBlank();
assertThat(first.status()).isNotBlank();
// uptime is a percentage; assert it decodes into the valid range.
assertThat(first.uptimePct30d()).isBetween(0.0, 100.0);
}

@Test
void headersEchoesRequestHeadersWithRedactedAuth() {
UtilitiesHeadersResponse resp = client.utilities().headers();

assertThat(resp.statusCode()).isIn(200, 203);
Map<String, String> headers = resp.values();
assertThat(headers).as("the server echoes the request headers it received").isNotEmpty();
// The SDK's User-Agent (marketdata-sdk-java/{version}) is always echoed back, regardless of how
// the server cases the header key — a stable proof the round-trip carried the SDK's headers.
assertThat(headers.toString()).contains("marketdata-sdk-java");
}

@Test
void userReturnsQuotaSnapshot() {
// The client constructor already validated this token against /user/ on startup, so a 200 is
// expected here; assert the quota record decodes rather than pinning to plan-specific numbers.
UtilitiesUserResponse resp = client.utilities().user();

assertThat(resp.statusCode()).isIn(200, 203);
User user = resp.values();
assertThat(user).isNotNull();
assertThat(user.requestsLimit()).as("a real plan exposes a request limit").isGreaterThan(0);
assertThat(user.requestsRemaining()).isGreaterThanOrEqualTo(0);
}

@Test
void statusValuesDecodeEveryRow() {
// Defensive: iterate the whole list so a malformed row anywhere trips a ParseError, not just
// the first.
List<ServiceStatus> services = client.utilities().status().values();
for (ServiceStatus s : services) {
assertThat(s.service()).isNotBlank();
}
}
}
44 changes: 25 additions & 19 deletions src/main/java/com/marketdata/sdk/AsyncSemaphore.java
Original file line number Diff line number Diff line change
Expand Up @@ -71,29 +71,35 @@ CompletableFuture<Void> acquire() {
* completed) without going through the counter. Otherwise the counter is incremented.
*/
void release() {
// Outer loop handles the TOCTOU window between pollFirst (inside the lock) and
// complete (outside): if the waiter is cancelled in that gap, complete(null) returns
// false and the permit hasn't actually been transferred. Retry with the next waiter,
// or fall through to the counter when the queue runs out of live waiters.
while (true) {
CompletableFuture<Void> next = null;
synchronized (lock) {
while (!waiters.isEmpty()) {
CompletableFuture<Void> w = waiters.pollFirst();
if (!w.isDone()) {
next = w;
break;
}
}
if (next == null) {
available++;
return;
// Retry while a transfer attempt loses the TOCTOU race (a polled waiter is cancelled between
// leaving the lock and complete(null)); the empty body re-runs the attempt. Exits once the
// permit is handed to a live waiter or returned to the counter.
while (!tryTransfer()) {
// retry with the next live waiter
}
}

/**
* One transfer attempt: hand the permit to the first live waiter, or return it to the counter
* when none remain. Returns {@code false} only when the polled waiter was cancelled in the gap
* between leaving the lock and {@code complete(null)} — the caller then retries.
*/
private boolean tryTransfer() {
CompletableFuture<Void> next = null;
synchronized (lock) {
while (!waiters.isEmpty()) {
CompletableFuture<Void> w = waiters.pollFirst();
if (!w.isDone()) {
next = w;
break;
}
}
if (next.complete(null)) {
return;
if (next == null) {
available++;
return true;
}
}
return next.complete(null);
}

/** Permits not currently held nor pending in the queue. */
Expand Down
64 changes: 64 additions & 0 deletions src/main/java/com/marketdata/sdk/ConfiguredResource.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.marketdata.sdk;

import java.util.List;

/**
* Package-private self-typed base for resources that carry the §3 universal query parameters as an
* immutable {@link RequestConfig}. Each setter returns a configured copy of the <em>concrete</em>
* resource type (via the {@code SELF} self-type and {@link #withConfig}), so endpoint chaining
* stays on the concrete class — {@code client.stocks().mode(LIVE).candles(req)} still reaches
* {@code candles}, which is declared on {@code StocksResource}, not here.
*
* <p>The five params declared here ({@code dateFormat}, {@code mode}, {@code limit}, {@code
* offset}, {@code columns}) are valid on both the typed path and the CSV facet. The CSV-only {@code
* human}/{@code headers} live on {@link FormattedResource}.
*
* <p>{@link #withConfig} is package-private so the internal {@link RequestConfig} never leaks onto
* the public API (ADR-007); the class itself is package-private for the same reason. The inherited
* setters remain public on the {@code public final} concrete subtypes, so Java and Kotlin callers
* invoke them directly on the concrete type.
*/
abstract class ConfiguredResource<SELF extends ConfiguredResource<SELF>> {

final HttpTransport transport;
final RequestConfig config;

ConfiguredResource(HttpTransport transport, RequestConfig config) {
this.transport = transport;
this.config = config;
}

/** Returns a copy of the concrete resource carrying {@code config}. */
abstract SELF withConfig(RequestConfig config);

/** Returns a copy that requests {@code dateformat} on every subsequent call. */
public SELF dateFormat(DateFormat dateFormat) {
return withConfig(config.withDateFormat(dateFormat));
}

/**
* Returns a copy with the data-freshness {@code mode} (cached honored only by quote endpoints).
*/
public SELF mode(Mode mode) {
return withConfig(config.withMode(mode));
}

/** Returns a copy with the pagination {@code limit}. */
public SELF limit(int limit) {
return withConfig(config.withLimit(limit));
}

/** Returns a copy with the pagination {@code offset}. */
public SELF offset(int offset) {
return withConfig(config.withOffset(offset));
}

/**
* Returns a copy that projects the response to the given columns (wire field names). Fields not
* requested decode to {@code null}; a requested column the API fails to return surfaces as a
* {@link com.marketdata.sdk.exception.ParseError} rather than a silent null.
*/
public SELF columns(String... columns) {
return withConfig(config.withColumns(List.of(columns)));
}
}
25 changes: 25 additions & 0 deletions src/main/java/com/marketdata/sdk/FormattedResource.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.marketdata.sdk;

/**
* Package-private self-typed base for the CSV format facets: a {@link ConfiguredResource} that
* additionally exposes the output-shaping {@code human}/{@code headers} params. Those two only
* cohere with CSV output (they reshape the rendered text), so the HTML facets — which expose no
* params at all — do <em>not</em> extend this.
*/
abstract class FormattedResource<SELF extends FormattedResource<SELF>>
extends ConfiguredResource<SELF> {

FormattedResource(HttpTransport transport, RequestConfig config) {
super(transport, config);
}

/** Returns a copy that renders human-friendly values instead of raw codes (CSV only). */
public SELF human(boolean human) {
return withConfig(config.withHuman(human));
}

/** Returns a copy that toggles the CSV header row (CSV only). */
public SELF headers(boolean headers) {
return withConfig(config.withHeaders(headers));
}
}
Loading
Loading