Skip to content

Commit c198327

Browse files
adds Utilities Resource
1 parent e3eb7c1 commit c198327

36 files changed

Lines changed: 1373 additions & 169 deletions

src/integrationTest/java/com/marketdata/sdk/markets/MarketsStatusIT.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
*
1515
* <p>Requires a valid {@code MARKETDATA_TOKEN} env var (or {@code .env} entry). Without one the
1616
* client enters demo mode and the {@code /markets/status/} endpoint is not on the demo allow-list,
17-
* so the test would receive an {@code AuthenticationError}.
17+
* so the test would receive an {@code AuthenticationException}.
1818
*
1919
* <p>Each scenario runs once for {@link CallMode#SYNC} and once for {@link CallMode#ASYNC} so we
2020
* satisfy SDK requirements §13's "tests must cover both sync and async variants for every endpoint"
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.marketdata.sdk.utilities;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.marketdata.sdk.MarketDataClient;
6+
import java.util.concurrent.CompletionException;
7+
import org.junit.jupiter.params.ParameterizedTest;
8+
import org.junit.jupiter.params.provider.EnumSource;
9+
10+
/**
11+
* Hits the live {@code GET /headers/} endpoint (root-level, no {@code /v1/} prefix). The endpoint
12+
* echoes back the headers the API received from us, so this is the strongest end-to-end check that
13+
* the SDK is actually sending the right Authorization, User-Agent, etc.
14+
*
15+
* <p>Gated by the {@code integrationTest} source set ({@code
16+
* MARKETDATA_RUN_INTEGRATION_TESTS=true}); requires a valid {@code MARKETDATA_TOKEN}.
17+
*
18+
* <p>Each scenario runs once for sync and once for async per SDK requirements §13.
19+
*/
20+
class UtilitiesHeadersIT {
21+
22+
@ParameterizedTest
23+
@EnumSource(CallMode.class)
24+
void headersEchoesUserAgentAndAuthorization(CallMode mode) {
25+
try (var client = MarketDataClient.builder().validateOnStartup(false).build()) {
26+
RequestHeaders headers = mode.headers(client.utilities());
27+
28+
assertThat(headers.isEmpty()).isFalse();
29+
30+
// The SDK's User-Agent must be sent on every request (SDK requirements §1.1):
31+
assertThat(headers.get("user-agent"))
32+
.as("the API echoes back the User-Agent we sent")
33+
.get()
34+
.asString()
35+
.startsWith("marketdata-sdk-java/");
36+
37+
// Authorization is forwarded (and partially redacted in the response per the API docs):
38+
assertThat(headers.get("authorization"))
39+
.as("Authorization header was sent and echoed (redacted)")
40+
.get()
41+
.asString()
42+
.startsWith("Bearer ");
43+
44+
// Accept comes from buildRequest in HttpTransport:
45+
assertThat(headers.get("accept")).get().asString().contains("application/json");
46+
}
47+
}
48+
49+
@ParameterizedTest
50+
@EnumSource(CallMode.class)
51+
void headerLookupsAreCaseInsensitive(CallMode mode) {
52+
try (var client = MarketDataClient.builder().validateOnStartup(false).build()) {
53+
RequestHeaders headers = mode.headers(client.utilities());
54+
55+
// The API normalizes keys to lowercase but our deserializer also forces lowercase, so
56+
// any of these capitalizations should resolve to the same value.
57+
assertThat(headers.get("User-Agent")).isEqualTo(headers.get("user-agent"));
58+
assertThat(headers.get("USER-AGENT")).isEqualTo(headers.get("user-agent"));
59+
}
60+
}
61+
62+
enum CallMode {
63+
SYNC {
64+
@Override
65+
RequestHeaders headers(UtilitiesResource r) {
66+
return r.headers();
67+
}
68+
},
69+
ASYNC {
70+
@Override
71+
RequestHeaders headers(UtilitiesResource r) {
72+
try {
73+
return r.headersAsync().join();
74+
} catch (CompletionException e) {
75+
if (e.getCause() instanceof RuntimeException re) {
76+
throw re;
77+
}
78+
throw e;
79+
}
80+
}
81+
};
82+
83+
abstract RequestHeaders headers(UtilitiesResource r);
84+
}
85+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.marketdata.sdk.utilities;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.marketdata.sdk.MarketDataClient;
6+
import java.util.concurrent.CompletionException;
7+
import org.junit.jupiter.params.ParameterizedTest;
8+
import org.junit.jupiter.params.provider.EnumSource;
9+
10+
/**
11+
* Hits the live {@code GET /status/} endpoint (root-level, no {@code /v1/} prefix). Gated by the
12+
* {@code integrationTest} source set ({@code MARKETDATA_RUN_INTEGRATION_TESTS=true}).
13+
*
14+
* <p>This endpoint does <em>not</em> require authentication, but the SDK will still send the {@code
15+
* Authorization} header if a token is configured — the API simply ignores it for status lookups. We
16+
* don't validate that here because there's no observable difference.
17+
*
18+
* <p>Each scenario runs once for sync and once for async per SDK requirements §13.
19+
*/
20+
class UtilitiesStatusIT {
21+
22+
@ParameterizedTest
23+
@EnumSource(CallMode.class)
24+
void statusReturnsServicesWithStructuralInvariants(CallMode mode) {
25+
try (var client = MarketDataClient.builder().validateOnStartup(false).build()) {
26+
ServiceStatus status = mode.status(client.utilities());
27+
28+
assertThat(status.services()).isNotEmpty();
29+
assertThat(status.services())
30+
.allSatisfy(
31+
s -> {
32+
assertThat(s.service()).isNotBlank();
33+
assertThat(s.status()).isIn("online", "offline");
34+
// online flag is consistent with the human-readable status:
35+
assertThat(s.online()).isEqualTo("online".equals(s.status()));
36+
assertThat(s.uptimePct30d()).isBetween(0.0, 1.0);
37+
assertThat(s.uptimePct90d()).isBetween(0.0, 1.0);
38+
assertThat(s.updated()).isNotNull();
39+
});
40+
}
41+
}
42+
43+
@ParameterizedTest
44+
@EnumSource(CallMode.class)
45+
void statusUrlIsRootLevelNotV1(CallMode mode) {
46+
// No direct way to introspect the URL from a successful call, but the
47+
// fact that the call doesn't 404 (which it would if we mistakenly hit
48+
// /v1/status/) is the implicit assertion. If this passes, the
49+
// RequestSpec.getAtRoot wiring is correctly skipping the version
50+
// prefix.
51+
try (var client = MarketDataClient.builder().validateOnStartup(false).build()) {
52+
ServiceStatus result = mode.status(client.utilities());
53+
assertThat(result).isNotNull();
54+
}
55+
}
56+
57+
enum CallMode {
58+
SYNC {
59+
@Override
60+
ServiceStatus status(UtilitiesResource r) {
61+
return r.status();
62+
}
63+
},
64+
ASYNC {
65+
@Override
66+
ServiceStatus status(UtilitiesResource r) {
67+
try {
68+
return r.statusAsync().join();
69+
} catch (CompletionException e) {
70+
if (e.getCause() instanceof RuntimeException re) {
71+
throw re;
72+
}
73+
throw e;
74+
}
75+
}
76+
};
77+
78+
abstract ServiceStatus status(UtilitiesResource r);
79+
}
80+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.marketdata.sdk.utilities;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.marketdata.sdk.MarketDataClient;
6+
import java.util.concurrent.CompletionException;
7+
import org.junit.jupiter.params.ParameterizedTest;
8+
import org.junit.jupiter.params.provider.EnumSource;
9+
10+
/**
11+
* Hits the live {@code GET /user/} endpoint and validates the round-trip. Gated by the {@code
12+
* integrationTest} source set ({@code MARKETDATA_RUN_INTEGRATION_TESTS=true}); requires a valid
13+
* {@code MARKETDATA_TOKEN}.
14+
*
15+
* <p>Each scenario runs once for sync and once for async per SDK requirements §13.
16+
*/
17+
class UtilitiesUserIT {
18+
19+
@ParameterizedTest
20+
@EnumSource(CallMode.class)
21+
void userReturnsAccountSnapshot(CallMode mode) {
22+
try (var client = MarketDataClient.builder().validateOnStartup(false).build()) {
23+
UserInfo info = mode.user(client.utilities());
24+
25+
// We don't know exact quota numbers (depends on the test account / time of day),
26+
// but the structural invariants hold for any well-formed account.
27+
assertThat(info.requestsLimit()).isPositive();
28+
assertThat(info.requestsRemaining()).isGreaterThanOrEqualTo(0);
29+
assertThat(info.requestsRemaining()).isLessThanOrEqualTo(info.requestsLimit());
30+
assertThat(info.optionsDataPermissions()).isNotNull();
31+
}
32+
}
33+
34+
@ParameterizedTest
35+
@EnumSource(CallMode.class)
36+
void afterUserCallTheClientHasAFreshRateLimitSnapshot(CallMode mode) {
37+
try (var client = MarketDataClient.builder().validateOnStartup(false).build()) {
38+
assertThat(client.getRateLimits()).isNull();
39+
40+
mode.user(client.utilities());
41+
42+
// §8.1: x-api-ratelimit-* headers populate the snapshot on the way back.
43+
assertThat(client.getRateLimits()).isNotNull();
44+
assertThat(client.getRateLimits().limit()).isPositive();
45+
}
46+
}
47+
48+
/** Mirrors the test-side {@code CallMode} from the unit suite. */
49+
enum CallMode {
50+
SYNC {
51+
@Override
52+
UserInfo user(UtilitiesResource r) {
53+
return r.user();
54+
}
55+
},
56+
ASYNC {
57+
@Override
58+
UserInfo user(UtilitiesResource r) {
59+
try {
60+
return r.userAsync().join();
61+
} catch (CompletionException e) {
62+
if (e.getCause() instanceof RuntimeException re) {
63+
throw re;
64+
}
65+
throw e;
66+
}
67+
}
68+
};
69+
70+
abstract UserInfo user(UtilitiesResource r);
71+
}
72+
}

src/main/java/com/marketdata/sdk/MarketDataClient.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.marketdata.sdk.internal.Version;
77
import com.marketdata.sdk.internal.http.HttpTransport;
88
import com.marketdata.sdk.markets.MarketsResource;
9+
import com.marketdata.sdk.utilities.UtilitiesResource;
910
import java.time.Duration;
1011
import java.util.logging.Level;
1112
import java.util.logging.Logger;
@@ -50,6 +51,7 @@ public final class MarketDataClient implements AutoCloseable {
5051
// one record-shaped object per resource group. Worth revisiting if the
5152
// resource count grows large.
5253
private final MarketsResource markets;
54+
private final UtilitiesResource utilities;
5355

5456
private MarketDataClient(Builder builder) {
5557
Configuration config = Configuration.loadFromProcess();
@@ -67,6 +69,7 @@ private MarketDataClient(Builder builder) {
6769

6870
this.transport = new HttpTransport(this.baseUrl, this.apiVersion, this.userAgent, this.token);
6971
this.markets = new MarketsResource(this.transport);
72+
this.utilities = new UtilitiesResource(this.transport);
7073

7174
LOG.log(
7275
Level.INFO,
@@ -80,8 +83,14 @@ private MarketDataClient(Builder builder) {
8083
LOG.log(Level.FINE, "Token: {0}", Tokens.redact(token));
8184
}
8285

83-
// SDK requirements §5: validate on startup by default. The actual
84-
// /user/ call lands with the user resource; this flag is the seam.
86+
// SDK requirements §5: validate the token by hitting /user/ unless either
87+
// (a) the caller explicitly disabled it, or (b) we're in demo mode (no
88+
// token to validate). This also populates the rate-limit snapshot at
89+
// construction time per §8.1 — the response headers feed the transport's
90+
// latestRateLimits as a side-effect of the call.
91+
if (validateOnStartup && !demoMode) {
92+
this.utilities.user();
93+
}
8594
}
8695

8796
public static Builder builder() {
@@ -97,6 +106,11 @@ public MarketsResource markets() {
97106
return markets;
98107
}
99108

109+
/** Façade for the {@code utilities} resource group ({@code /v1/user/}, plus more later). */
110+
public UtilitiesResource utilities() {
111+
return utilities;
112+
}
113+
100114
// ---------------------------------------------------------------------
101115
// Configuration accessors
102116
// ---------------------------------------------------------------------

src/main/java/com/marketdata/sdk/exception/AuthenticationError.java

Lines changed: 0 additions & 15 deletions
This file was deleted.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.marketdata.sdk.exception;
2+
3+
import org.jspecify.annotations.Nullable;
4+
5+
/** The API rejected the credentials (HTTP 401). */
6+
public final class AuthenticationException extends MarketDataException {
7+
8+
public AuthenticationException(String message, ErrorContext context) {
9+
super(message, context, null);
10+
}
11+
12+
public AuthenticationException(String message, ErrorContext context, @Nullable Throwable cause) {
13+
super(message, context, cause);
14+
}
15+
}

src/main/java/com/marketdata/sdk/exception/BadRequestError.java

Lines changed: 0 additions & 15 deletions
This file was deleted.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.marketdata.sdk.exception;
2+
3+
import org.jspecify.annotations.Nullable;
4+
5+
/** The request was malformed or invalid (HTTP 400 / 422). */
6+
public final class BadRequestException extends MarketDataException {
7+
8+
public BadRequestException(String message, ErrorContext context) {
9+
super(message, context, null);
10+
}
11+
12+
public BadRequestException(String message, ErrorContext context, @Nullable Throwable cause) {
13+
super(message, context, cause);
14+
}
15+
}

src/main/java/com/marketdata/sdk/exception/MarketDataException.java

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@
1616
* any HTTP request is dispatched.
1717
*/
1818
public abstract sealed class MarketDataException extends RuntimeException
19-
permits AuthenticationError,
20-
BadRequestError,
21-
NotFoundError,
22-
RateLimitError,
23-
ServerError,
24-
NetworkError,
25-
ParseError {
19+
permits AuthenticationException,
20+
BadRequestException,
21+
NotFoundException,
22+
RateLimitException,
23+
ServerException,
24+
NetworkException,
25+
ParseException {
2626

2727
private static final ZoneId EASTERN = ZoneId.of("America/New_York");
2828
private static final DateTimeFormatter TIMESTAMP_FORMAT =

0 commit comments

Comments
 (0)