diff --git a/CHANGELOG.md b/CHANGELOG.md index ac2a953..3964c48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.5.0] — 2026-05-25 + +### Added + +- `QTSurfer.auth(apikey)` (plus `auth()` env-var overload and `auth(apikey, AuthOptions)`) — one-call helper that exchanges a long-lived API key for a short-lived JWT and returns an `AuthenticatedClient`. The returned session mirrors the existing `QTSurfer` surface (`compile`, `backtest`, `exchanges`, `instruments`, `tickers`, `klines`) but transparently refreshes the JWT once on a 401 before retrying. +- `com.qtsurfer.api.sdk.auth.TokenStore` interface (`load` / `save` / `clear`) and a default `InMemoryTokenStore`. Adopters can plug in a file, secret manager, or desktop keychain. `AuthOptions` is the configuration record (base URL, token store, HTTP client, executor) — defaults to `https://api.qtsurfer.com/v1` and an in-memory store. +- `QTSURFER_APIKEY` environment variable: read by `QTSurfer.auth(null, ...)` / `QTSurfer.auth()` when no API key argument is passed. +- `QTSAuthError` (subclass of `QTSError`) raised when no API key is available or the JWT exchange fails. + +### Changed + +- Bumped `com.qtsurfer:api-client-java` to `0.3.1` (adds `AuthApi`, `AuthTokenResponse`, `AuthTokenError`). +- `DownloadFormat#wire()` is now `public` so the auth-session can pass the underlying `ExchangeBinaryDownloads.Format` through. + ## [0.4.1] — 2026-05-17 ### Fixed diff --git a/README.md b/README.md index 79414f8..d39711d 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Where `com.qtsurfer:api-client-java` gives you one method per endpoint, this pac com.qtsurfer sdk-java - 0.4.1 + x.x.x ``` @@ -47,10 +47,87 @@ The transitive `com.qtsurfer:api-client-java` and `dev.failsafe:failsafe` come a ### Maven Central (future) -Once published to Central, the coordinate will be `com.qtsurfer:sdk:0.4.1`. +Once published to Central, the coordinate will be `com.qtsurfer:sdk-java:x.x.x`. ## Quick start +One call: API key in, ready-to-use session out. JWT refresh on 401 is +handled for you. + +```java +import com.qtsurfer.api.client.model.ResultMap; +import com.qtsurfer.api.sdk.BacktestRequest; +import com.qtsurfer.api.sdk.QTSurfer; +import com.qtsurfer.api.sdk.auth.AuthenticatedClient; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; + +// Reads QTSURFER_APIKEY from env when no argument is passed. +AuthenticatedClient qts = QTSurfer.auth(); +// Or: AuthenticatedClient qts = QTSurfer.auth("ak_..."); + +ResultMap result = qts.backtest( + BacktestRequest.builder() + .strategy(Files.readString(Path.of("Strategy.java"))) + .exchangeId("binance") + .instrument("BTC/USDT") + .from("2026-04-13T00:00:00Z") + .to("2026-04-14T00:00:00Z") + .storeSignals(true) + .build()).join(); + +System.out.println("PnL: " + result.getPnlTotal()); +System.out.println("Trades: " + result.getTotalTrades()); +``` + +### Environment + +| Variable | Purpose | +| ----------------- | ---------------------------------------------------- | +| `QTSURFER_APIKEY` | API key consumed by `QTSurfer.auth()` when no arg is passed | + +### Pluggable token storage + +Tokens are kept in memory by default. Implement `TokenStore` to back +tokens by an on-disk file, a secret manager, or a desktop keychain: + +```java +import com.qtsurfer.api.client.model.AuthTokenResponse; +import com.qtsurfer.api.sdk.QTSurfer; +import com.qtsurfer.api.sdk.auth.AuthOptions; +import com.qtsurfer.api.sdk.auth.TokenStore; + +TokenStore fileStore = new TokenStore() { + @Override public AuthTokenResponse load() { /* read from disk */ return null; } + @Override public void save(AuthTokenResponse t) { /* write to disk */ } + @Override public void clear() { /* delete the file */ } +}; + +var qts = QTSurfer.auth(null, + AuthOptions.builder().store(fileStore).build()); +``` + +`AuthOptions` also accepts `baseUrl`, `httpClient`, and `executor` for +staging targets, custom transports, and dedicated thread pools. + +### Lower-level: hand-managed JWT + +If you already hold a JWT and want to manage refresh yourself, the +`QTSurfer.builder()` path is unchanged: + +```java +import com.qtsurfer.api.sdk.QTSurfer; + +QTSurfer qts = QTSurfer.builder() + .baseUrl("https://api.qtsurfer.com/v1") + .token(System.getenv("JWT_API_TOKEN")) + .build(); +``` + +### Decomposed pipeline (advanced) + ```java import com.qtsurfer.api.client.model.ResultMap; import com.qtsurfer.api.sdk.BacktestOptions; diff --git a/pom.xml b/pom.xml index 5c07f93..6926f8e 100644 --- a/pom.xml +++ b/pom.xml @@ -5,8 +5,8 @@ 4.0.0 com.qtsurfer - sdk - 0.4.1 + sdk-java + 0.5.0 jar QTSurfer SDK @@ -48,7 +48,7 @@ UTF-8 UTF-8 - 0.2.0 + 0.3.1 3.3.2 2.0.16 2.18.2 diff --git a/src/main/java/com/qtsurfer/api/sdk/DownloadFormat.java b/src/main/java/com/qtsurfer/api/sdk/DownloadFormat.java index dabdc86..133dc7d 100644 --- a/src/main/java/com/qtsurfer/api/sdk/DownloadFormat.java +++ b/src/main/java/com/qtsurfer/api/sdk/DownloadFormat.java @@ -22,7 +22,12 @@ public enum DownloadFormat { this.wire = wire; } - ExchangeBinaryDownloads.Format wire() { + /** + * Internal: the underlying {@code ExchangeBinaryDownloads.Format} used by + * the api-client. Exposed so the SDK's own helpers can pass it through + * without re-encoding. + */ + public ExchangeBinaryDownloads.Format wire() { return wire; } } diff --git a/src/main/java/com/qtsurfer/api/sdk/QTSurfer.java b/src/main/java/com/qtsurfer/api/sdk/QTSurfer.java index a624de9..a5cb195 100644 --- a/src/main/java/com/qtsurfer/api/sdk/QTSurfer.java +++ b/src/main/java/com/qtsurfer/api/sdk/QTSurfer.java @@ -8,6 +8,8 @@ import com.qtsurfer.api.client.model.Exchange; import com.qtsurfer.api.client.model.InstrumentDetail; import com.qtsurfer.api.client.model.ResultMap; +import com.qtsurfer.api.sdk.auth.AuthOptions; +import com.qtsurfer.api.sdk.auth.AuthenticatedClient; import com.qtsurfer.api.sdk.errors.QTSDownloadError; import com.qtsurfer.api.sdk.errors.QTSError; import com.qtsurfer.api.sdk.internal.HttpStrategyCompileClient; @@ -173,6 +175,32 @@ private static String describe(ApiException e) { return "HTTP " + e.getCode(); } + /** + * One-call setup: exchange an API key for a short-lived JWT and return + * an {@link AuthenticatedClient} that mirrors this SDK's surface + * (compile / backtest / exchanges / instruments / tickers / klines) + * with automatic refresh-on-401. + * + *

If {@code apikey} is {@code null} or blank, the value is read from + * the {@code QTSURFER_APIKEY} environment variable. + * + * @throws com.qtsurfer.api.sdk.errors.QTSAuthError when no API key is + * available or the initial JWT exchange fails. + */ + public static AuthenticatedClient auth(String apikey) { + return AuthenticatedClient.auth(apikey); + } + + /** Overload accepting an {@link AuthOptions} (base URL, token store, executor). */ + public static AuthenticatedClient auth(String apikey, AuthOptions options) { + return AuthenticatedClient.auth(apikey, options); + } + + /** Overload that reads the API key from {@code QTSURFER_APIKEY}. */ + public static AuthenticatedClient auth() { + return AuthenticatedClient.auth(); + } + public static Builder builder() { return new Builder(); } public static final class Builder { diff --git a/src/main/java/com/qtsurfer/api/sdk/auth/AuthOptions.java b/src/main/java/com/qtsurfer/api/sdk/auth/AuthOptions.java new file mode 100644 index 0000000..37ddf37 --- /dev/null +++ b/src/main/java/com/qtsurfer/api/sdk/auth/AuthOptions.java @@ -0,0 +1,53 @@ +package com.qtsurfer.api.sdk.auth; + +import java.net.URI; +import java.net.http.HttpClient; +import java.util.Objects; +import java.util.concurrent.ExecutorService; + +/** + * Configuration for {@link AuthenticatedClient}. Mirrors + * {@code QTSurferOptions} but replaces the static {@code token} with a + * pluggable {@link TokenStore}. + * + * @param baseUrl API base URL. Defaults to {@code https://api.qtsurfer.com/v1}. + * @param store pluggable token store; defaults to {@link InMemoryTokenStore}. + * @param httpClient optional custom {@link HttpClient}. + * @param executor executor that runs the async workflow. + */ +public record AuthOptions( + URI baseUrl, + TokenStore store, + HttpClient httpClient, + ExecutorService executor +) { + public static final URI DEFAULT_BASE_URL = URI.create("https://api.qtsurfer.com/v1"); + + public AuthOptions { + Objects.requireNonNull(baseUrl, "baseUrl"); + Objects.requireNonNull(store, "store"); + } + + public static AuthOptions defaults() { + return builder().build(); + } + + public static Builder builder() { return new Builder(); } + + public static final class Builder { + private URI baseUrl = DEFAULT_BASE_URL; + private TokenStore store = new InMemoryTokenStore(); + private HttpClient httpClient; + private ExecutorService executor; + + public Builder baseUrl(URI baseUrl) { this.baseUrl = baseUrl; return this; } + public Builder baseUrl(String baseUrl) { this.baseUrl = URI.create(baseUrl); return this; } + public Builder store(TokenStore store) { this.store = store; return this; } + public Builder httpClient(HttpClient httpClient) { this.httpClient = httpClient; return this; } + public Builder executor(ExecutorService executor) { this.executor = executor; return this; } + + public AuthOptions build() { + return new AuthOptions(baseUrl, store, httpClient, executor); + } + } +} diff --git a/src/main/java/com/qtsurfer/api/sdk/auth/AuthenticatedClient.java b/src/main/java/com/qtsurfer/api/sdk/auth/AuthenticatedClient.java new file mode 100644 index 0000000..e2b0a32 --- /dev/null +++ b/src/main/java/com/qtsurfer/api/sdk/auth/AuthenticatedClient.java @@ -0,0 +1,348 @@ +package com.qtsurfer.api.sdk.auth; + +import com.qtsurfer.api.client.api.AuthApi; +import com.qtsurfer.api.client.api.BacktestingApi; +import com.qtsurfer.api.client.api.ExchangeApi; +import com.qtsurfer.api.client.binary.ExchangeBinaryDownloads; +import com.qtsurfer.api.client.invoker.ApiClient; +import com.qtsurfer.api.client.invoker.ApiException; +import com.qtsurfer.api.client.model.AuthTokenResponse; +import com.qtsurfer.api.client.model.Exchange; +import com.qtsurfer.api.client.model.InstrumentDetail; +import com.qtsurfer.api.client.model.ResultMap; +import com.qtsurfer.api.sdk.BacktestOptions; +import com.qtsurfer.api.sdk.BacktestRequest; +import com.qtsurfer.api.sdk.DownloadFormat; +import com.qtsurfer.api.sdk.Strategy; +import com.qtsurfer.api.sdk.errors.QTSAuthError; +import com.qtsurfer.api.sdk.errors.QTSDownloadError; +import com.qtsurfer.api.sdk.errors.QTSError; +import com.qtsurfer.api.sdk.internal.HttpStrategyCompileClient; +import com.qtsurfer.api.sdk.workflows.BacktestWorkflow; + +import java.io.InputStream; +import java.net.http.HttpRequest; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Authenticated SDK session. + * + *

Created by {@link com.qtsurfer.api.sdk.QTSurfer#auth(String)} (or the + * overload that accepts an {@link AuthOptions}). Wraps the underlying + * api-client, owns a JWT (in memory by default, or in the provided + * {@link TokenStore}), and transparently re-exchanges the API key for a + * fresh JWT when a call returns 401. + * + *

Exposes the same workflow surface as {@code QTSurfer}: {@code compile}, + * {@code backtest}, {@code exchanges}, {@code instruments}, {@code tickers}, + * {@code klines}. Method semantics are unchanged — only the bearer token + * management differs. + * + *

Refresh policy: a 401 from any wrapped call triggers exactly one + * {@code POST /v1/auth/token} exchange, then the original call is retried + * once. A second 401 is surfaced to the caller. + */ +public final class AuthenticatedClient { + + static final String APIKEY_ENV_VAR = "QTSURFER_APIKEY"; + + private final AuthOptions options; + private final AuthApi authApi; + private final BacktestWorkflow backtestWorkflow; + private final ExchangeBinaryDownloads downloads; + private final ExchangeApi exchangeApi; + private final AtomicReference cached = new AtomicReference<>(); + + AuthenticatedClient( + AuthOptions options, + AuthApi authApi, + BacktestWorkflow backtestWorkflow, + ExchangeBinaryDownloads downloads, + ExchangeApi exchangeApi) { + this.options = options; + this.authApi = authApi; + this.backtestWorkflow = backtestWorkflow; + this.downloads = downloads; + this.exchangeApi = exchangeApi; + } + + /** Configuration in use by this session. */ + public AuthOptions options() { return options; } + + /** Most recently minted token, or {@code null} if no exchange has happened yet. */ + public AuthTokenResponse token() { return cached.get(); } + + /** + * Force a fresh JWT exchange via {@code POST /v1/auth/token}. Bypasses + * the cache; the returned token is also written to the configured + * {@link TokenStore}. + */ + public synchronized AuthTokenResponse refresh() { + AuthTokenResponse fresh; + try { + fresh = authApi.auth(); + } catch (ApiException e) { + throw new QTSAuthError("auth() failed: HTTP " + e.getCode(), e); + } + if (fresh == null) { + throw new QTSAuthError("auth() returned an empty response"); + } + cached.set(fresh); + mirror(); + options.store().save(fresh); + return fresh; + } + + /** + * Return the cached token, seeding from the {@link TokenStore} on first + * use, and minting a new one if neither cache nor store hold one. + */ + public AuthTokenResponse ensureToken() { + AuthTokenResponse t = cached.get(); + if (t != null) return t; + AuthTokenResponse stored = options.store().load(); + if (stored != null) { + cached.set(stored); + mirror(); + return stored; + } + return refresh(); + } + + /** Drop the cached token (in memory and in the store). */ + public void clear() { + cached.set(null); + mirror(); + options.store().clear(); + } + + // ---- Workflow surface (mirrors QTSurfer) ---- + + public CompletableFuture compile(String source) { + return compile(source, BacktestOptions.defaults()); + } + + public CompletableFuture compile(String source, BacktestOptions opts) { + Objects.requireNonNull(source, "source"); + return withRefreshOn401Async(() -> backtestWorkflow.compile(source, opts)); + } + + public CompletableFuture compile(BacktestRequest request) { + Objects.requireNonNull(request, "request"); + return compile(request.strategy(), BacktestOptions.defaults()); + } + + public CompletableFuture compile(BacktestRequest request, BacktestOptions opts) { + Objects.requireNonNull(request, "request"); + return compile(request.strategy(), opts); + } + + public CompletableFuture backtest(BacktestRequest request) { + return backtest(request, BacktestOptions.defaults()); + } + + public CompletableFuture backtest(BacktestRequest request, BacktestOptions opts) { + Objects.requireNonNull(request, "request"); + return withRefreshOn401Async(() -> backtestWorkflow.runFull(request, opts)); + } + + public List exchanges() { + return withRefreshOn401(() -> { + try { + return exchangeApi.getExchanges(); + } catch (ApiException e) { + throw new QTSError("exchanges call failed: " + describe(e), e); + } + }); + } + + public List instruments(String exchangeId) { + Objects.requireNonNull(exchangeId, "exchangeId"); + return withRefreshOn401(() -> { + try { + return exchangeApi.getInstruments(exchangeId); + } catch (ApiException e) { + throw new QTSError("instruments call failed: " + describe(e), e); + } + }); + } + + public InputStream tickers(String exchangeId, String base, String quote, String hour) { + return tickers(exchangeId, base, quote, hour, DownloadFormat.LASTRA); + } + + public InputStream tickers(String exchangeId, String base, String quote, String hour, DownloadFormat format) { + Objects.requireNonNull(format, "format"); + return withRefreshOn401(() -> { + try { + return downloads.getTickersHour(exchangeId, base, quote, hour, format.wire()); + } catch (ApiException e) { + throw new QTSDownloadError("tickers download failed: " + describe(e), e); + } + }); + } + + public InputStream klines(String exchangeId, String base, String quote, String hour) { + return klines(exchangeId, base, quote, hour, DownloadFormat.LASTRA); + } + + public InputStream klines(String exchangeId, String base, String quote, String hour, DownloadFormat format) { + Objects.requireNonNull(format, "format"); + return withRefreshOn401(() -> { + try { + return downloads.getKlinesHour(exchangeId, base, quote, hour, format.wire()); + } catch (ApiException e) { + throw new QTSDownloadError("klines download failed: " + describe(e), e); + } + }); + } + + // ---- Refresh-on-401 plumbing ---- + + private T withRefreshOn401(Supplier call) { + ensureToken(); + try { + return call.get(); + } catch (QTSError e) { + if (!isUnauthorized(e)) throw e; + cached.set(null); + mirror(); + refresh(); + return call.get(); + } + } + + private CompletableFuture withRefreshOn401Async(Supplier> call) { + ensureToken(); + return call.get().handle((value, ex) -> { + if (ex == null) return CompletableFuture.completedFuture(value); + Throwable cause = unwrap(ex); + if (!(cause instanceof QTSError qts) || !isUnauthorized(qts)) { + CompletableFuture fail = new CompletableFuture<>(); + fail.completeExceptionally(ex); + return fail; + } + cached.set(null); + mirror(); + refresh(); + return call.get(); + }).thenCompose(f -> f); + } + + private static boolean isUnauthorized(QTSError e) { + Throwable c = e.getCause(); + return c instanceof ApiException api && api.getCode() == 401; + } + + private static Throwable unwrap(Throwable t) { + if (t instanceof CompletionException ce && ce.getCause() != null) { + return ce.getCause(); + } + return t; + } + + private static String describe(ApiException e) { + if (e.getResponseBody() != null && !e.getResponseBody().isBlank()) { + return "HTTP " + e.getCode() + " — " + e.getResponseBody(); + } + return "HTTP " + e.getCode(); + } + + // ---- Factory ---- + + /** + * Mint a fresh session from the given API key. + * + *

If {@code apikey} is {@code null} or blank, the value is read from + * the {@code QTSURFER_APIKEY} environment variable. A {@link QTSAuthError} + * is raised when neither source yields a usable API key, or when the + * initial JWT exchange fails. + */ + public static AuthenticatedClient auth(String apikey, AuthOptions opts) { + String resolved = resolveApikey(apikey); + AuthOptions o = opts != null ? opts : AuthOptions.defaults(); + + // ApiClient for the mint call — interceptor pins X-API-Key. + ApiClient mintClient = buildApiClient(o, b -> b.header("X-API-Key", resolved)); + AuthApi mintApi = new AuthApi(mintClient); + + // Container ref shared between the request interceptor and the + // session itself, so refresh() swapping the cached token is visible + // to subsequent api-client calls without rebuilding the client. + AtomicReference shared = new AtomicReference<>(); + ApiClient apiClient = buildApiClient(o, b -> { + AuthTokenResponse t = shared.get(); + if (t != null && t.getAccessToken() != null) { + b.header("Authorization", "Bearer " + t.getAccessToken()); + } + }); + + BacktestingApi backtestingApi = new BacktestingApi(apiClient); + ExecutorService exec = o.executor() != null ? o.executor() : ForkJoinPool.commonPool(); + BacktestWorkflow workflow = new BacktestWorkflow( + new HttpStrategyCompileClient(apiClient), backtestingApi, exec); + ExchangeBinaryDownloads downloads = new ExchangeBinaryDownloads(apiClient); + ExchangeApi exchangeApi = new ExchangeApi(apiClient); + + AuthenticatedClient session = new AuthenticatedClient( + o, mintApi, workflow, downloads, exchangeApi); + // Keep `shared` mirrored to the session's cache via a bridge thread-safely. + session.linkBearerRef(shared); + + // Initial mint so the session is usable immediately. + session.ensureToken(); + return session; + } + + public static AuthenticatedClient auth(String apikey) { + return auth(apikey, AuthOptions.defaults()); + } + + public static AuthenticatedClient auth() { + return auth(null, AuthOptions.defaults()); + } + + /** + * Wire the bearer-token interceptor's reference to this session's cache. + * Called by the factory. + */ + private void linkBearerRef(AtomicReference bearerRef) { + this.bearerRef = bearerRef; + AuthTokenResponse current = cached.get(); + if (current != null) bearerRef.set(current); + } + + private volatile AtomicReference bearerRef; + + private void mirror() { + AtomicReference r = bearerRef; + if (r != null) r.set(cached.get()); + } + + private static ApiClient buildApiClient(AuthOptions opts, Consumer headers) { + ApiClient c = new ApiClient(); + c.updateBaseUri(opts.baseUrl().toString()); + c.setRequestInterceptor(headers); + return c; + } + + private static String resolveApikey(String explicit) { + return resolveApikey(explicit, System.getenv(APIKEY_ENV_VAR)); + } + + /** Package-private overload exposed for unit tests. */ + static String resolveApikey(String explicit, String envValue) { + if (explicit != null && !explicit.isBlank()) return explicit; + if (envValue != null && !envValue.isBlank()) return envValue; + throw new QTSAuthError( + "auth() requires an apikey (argument or " + APIKEY_ENV_VAR + " env var)"); + } +} diff --git a/src/main/java/com/qtsurfer/api/sdk/auth/InMemoryTokenStore.java b/src/main/java/com/qtsurfer/api/sdk/auth/InMemoryTokenStore.java new file mode 100644 index 0000000..7b0db30 --- /dev/null +++ b/src/main/java/com/qtsurfer/api/sdk/auth/InMemoryTokenStore.java @@ -0,0 +1,30 @@ +package com.qtsurfer.api.sdk.auth; + +import com.qtsurfer.api.client.model.AuthTokenResponse; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * Default {@link TokenStore} — holds the most recent token in a single + * in-memory slot. Lost on JVM exit. Sufficient for short-lived scripts and + * for tests. + */ +public final class InMemoryTokenStore implements TokenStore { + + private final AtomicReference ref = new AtomicReference<>(); + + @Override + public AuthTokenResponse load() { + return ref.get(); + } + + @Override + public void save(AuthTokenResponse token) { + ref.set(token); + } + + @Override + public void clear() { + ref.set(null); + } +} diff --git a/src/main/java/com/qtsurfer/api/sdk/auth/TokenStore.java b/src/main/java/com/qtsurfer/api/sdk/auth/TokenStore.java new file mode 100644 index 0000000..aaafd90 --- /dev/null +++ b/src/main/java/com/qtsurfer/api/sdk/auth/TokenStore.java @@ -0,0 +1,27 @@ +package com.qtsurfer.api.sdk.auth; + +import com.qtsurfer.api.client.model.AuthTokenResponse; + +/** + * Pluggable token persistence strategy for {@link AuthenticatedClient}. + * + *

The SDK ships an {@link InMemoryTokenStore} as the default. Adopters + * implement this SAM to back tokens by an on-disk file, a secret manager, + * etc. The SDK calls {@link #load()} once at session start to seed any + * previously cached token, {@link #save(AuthTokenResponse)} after every + * successful {@code auth()} / refresh, and {@link #clear()} when the + * session is explicitly invalidated. + * + *

Implementations are expected to be thread-safe. + */ +public interface TokenStore { + + /** Return the persisted token, or {@code null} if none. */ + AuthTokenResponse load(); + + /** Persist the token returned by {@code POST /v1/auth/token}. */ + void save(AuthTokenResponse token); + + /** Drop any persisted token. */ + void clear(); +} diff --git a/src/main/java/com/qtsurfer/api/sdk/errors/QTSAuthError.java b/src/main/java/com/qtsurfer/api/sdk/errors/QTSAuthError.java new file mode 100644 index 0000000..010f6c2 --- /dev/null +++ b/src/main/java/com/qtsurfer/api/sdk/errors/QTSAuthError.java @@ -0,0 +1,18 @@ +package com.qtsurfer.api.sdk.errors; + +/** + * Thrown when the {@code auth()} helper cannot mint a JWT — typically because + * no API key was supplied (neither argument nor {@code QTSURFER_APIKEY} + * environment variable), or the API key exchange returned 401 / a non-success + * status from {@code POST /v1/auth/token}. + */ +public class QTSAuthError extends QTSError { + + public QTSAuthError(String message) { + super(message); + } + + public QTSAuthError(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/test/java/com/qtsurfer/api/sdk/auth/AuthenticatedClientTest.java b/src/test/java/com/qtsurfer/api/sdk/auth/AuthenticatedClientTest.java new file mode 100644 index 0000000..4ab9e32 --- /dev/null +++ b/src/test/java/com/qtsurfer/api/sdk/auth/AuthenticatedClientTest.java @@ -0,0 +1,263 @@ +package com.qtsurfer.api.sdk.auth; + +import com.qtsurfer.api.client.model.AuthTokenResponse; +import com.qtsurfer.api.sdk.QTSurfer; +import com.qtsurfer.api.sdk.errors.QTSAuthError; +import com.qtsurfer.api.sdk.errors.QTSDownloadError; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the apikey -> JWT auth helper. All HTTP traffic is served + * by a local {@link HttpServer} on a free port — no live network calls. + */ +class AuthenticatedClientTest { + + private HttpServer server; + private String baseUrl; + private final List requests = new ArrayList<>(); + private final List tokenResponses = new ArrayList<>(); + private final AtomicInteger tokenCalls = new AtomicInteger(); + private final List tickerStatuses = new ArrayList<>(); + private final AtomicInteger tickerCalls = new AtomicInteger(); + + record RequestRecord(String path, String method, String authorization, String apikey) {} + + @BeforeEach + void start() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + requests.add(new RequestRecord( + exchange.getRequestURI().getPath(), + exchange.getRequestMethod(), + exchange.getRequestHeaders().getFirst("Authorization"), + exchange.getRequestHeaders().getFirst("X-API-Key"))); + String path = exchange.getRequestURI().getPath(); + byte[] body; + int status; + String ctype; + if (path.endsWith("/auth/token")) { + int idx = tokenCalls.getAndIncrement(); + String resp = idx < tokenResponses.size() + ? tokenResponses.get(idx) + : tokenResponses.get(tokenResponses.size() - 1); + if (resp.startsWith("401:")) { + status = 401; + body = resp.substring(4).getBytes(StandardCharsets.UTF_8); + } else { + status = 200; + body = resp.getBytes(StandardCharsets.UTF_8); + } + ctype = "application/json"; + } else if (path.contains("/tickers/")) { + int idx = tickerCalls.getAndIncrement(); + status = idx < tickerStatuses.size() + ? tickerStatuses.get(idx) + : tickerStatuses.get(tickerStatuses.size() - 1); + body = status == 200 + ? "LASTRA-OK".getBytes(StandardCharsets.UTF_8) + : ("{\"error\":\"" + status + "\"}").getBytes(StandardCharsets.UTF_8); + ctype = status == 200 ? "application/vnd.lastra" : "application/json"; + } else { + status = 404; + body = new byte[0]; + ctype = "application/json"; + } + exchange.getResponseHeaders().add("Content-Type", ctype); + exchange.sendResponseHeaders(status, body.length == 0 ? -1 : body.length); + try (var os = exchange.getResponseBody()) { + if (body.length > 0) os.write(body); + } + }); + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/v1"; + } + + @AfterEach + void stop() { + server.stop(0); + } + + private static String jwt(String access, String tier) { + return "{\"access_token\":\"" + access + + "\",\"token_type\":\"Bearer\",\"expires_in\":3600,\"tier\":\"" + + tier + "\"}"; + } + + private AuthOptions opts() { + return AuthOptions.builder().baseUrl(baseUrl).build(); + } + + @Test + void usesExplicitApikeyForTheMintCall() { + tokenResponses.add(jwt("jwt-explicit", "free")); + AuthenticatedClient session = QTSurfer.auth("ak_explicit", opts()); + assertEquals("jwt-explicit", session.token().getAccessToken()); + assertEquals(1, tokenCalls.get()); + RequestRecord first = requests.get(0); + assertEquals("ak_explicit", first.apikey()); + assertEquals("POST", first.method()); + } + + @Test + void readsApikeyFromEnvWhenNoArgPassed() { + // End-to-end: a null arg with no env raises QTSAuthError. + AuthOptions o = opts(); + QTSAuthError ex = assertThrows(QTSAuthError.class, () -> QTSurfer.auth(null, o)); + assertTrue(ex.getMessage().contains("QTSURFER_APIKEY")); + } + + @Test + void resolveApikeyPrefersExplicitOverEnv() { + // env-var resolution is unit-tested via the package-private overload + // (the process env can't be mutated portably in a JUnit test). + assertEquals("ak_arg", + AuthenticatedClient.resolveApikey("ak_arg", "ak_env")); + assertEquals("ak_env", + AuthenticatedClient.resolveApikey(null, "ak_env")); + assertEquals("ak_env", + AuthenticatedClient.resolveApikey("", "ak_env")); + assertEquals("ak_env", + AuthenticatedClient.resolveApikey(" ", "ak_env")); + assertThrows(QTSAuthError.class, + () -> AuthenticatedClient.resolveApikey(null, null)); + assertThrows(QTSAuthError.class, + () -> AuthenticatedClient.resolveApikey(null, "")); + } + + @Test + void blankApikeyTreatedAsMissing() { + QTSAuthError ex = assertThrows(QTSAuthError.class, () -> QTSurfer.auth(" ", opts())); + assertTrue(ex.getMessage().contains("apikey")); + } + + @Test + void mintFailureSurfacesAsQtsAuthError() { + tokenResponses.add("401:{\"code\":\"invalid_apikey\",\"message\":\"no\"}"); + QTSAuthError ex = assertThrows(QTSAuthError.class, + () -> QTSurfer.auth("ak_bad", opts())); + assertTrue(ex.getMessage().contains("401")); + } + + @Test + void saveCalledOnTokenStoreOnEachMint() { + tokenResponses.add(jwt("jwt-stored", "elite")); + AtomicReference stored = new AtomicReference<>(); + AtomicInteger saves = new AtomicInteger(); + TokenStore store = new TokenStore() { + @Override public AuthTokenResponse load() { return null; } + @Override public void save(AuthTokenResponse t) { stored.set(t); saves.incrementAndGet(); } + @Override public void clear() { stored.set(null); } + }; + QTSurfer.auth("ak", AuthOptions.builder().baseUrl(baseUrl).store(store).build()); + assertEquals(1, saves.get()); + assertNotNull(stored.get()); + assertEquals("jwt-stored", stored.get().getAccessToken()); + } + + @Test + void seedsFromTokenStoreWithoutMintingWhenLoadReturnsToken() { + AuthTokenResponse cached = new AuthTokenResponse(); + cached.setAccessToken("jwt-cached"); + cached.setTokenType(AuthTokenResponse.TokenTypeEnum.BEARER); + cached.setExpiresIn(3600); + TokenStore store = new TokenStore() { + @Override public AuthTokenResponse load() { return cached; } + @Override public void save(AuthTokenResponse t) {} + @Override public void clear() {} + }; + // No tokenResponses primed; if the SDK mints, the server returns 404. + AuthenticatedClient session = QTSurfer.auth("ak", + AuthOptions.builder().baseUrl(baseUrl).store(store).build()); + assertEquals("jwt-cached", session.token().getAccessToken()); + assertEquals(0, tokenCalls.get()); + } + + @Test + void refreshOn401RetriesAndSucceeds() throws IOException { + tokenResponses.add(jwt("jwt-1", "free")); + tokenResponses.add(jwt("jwt-2", "free")); + tickerStatuses.add(401); + tickerStatuses.add(200); + + AuthenticatedClient session = QTSurfer.auth("ak", opts()); + try (InputStream in = session.tickers("binance", "BTC", "USDT", "2026-01-15T10")) { + byte[] all = in.readAllBytes(); + assertEquals("LASTRA-OK", new String(all, StandardCharsets.UTF_8)); + } + // 2 mints: initial + refresh. + assertEquals(2, tokenCalls.get()); + // 2 ticker calls: original (401) + retry (200). + assertEquals(2, tickerCalls.get()); + // Second ticker carried the refreshed JWT. + RequestRecord retried = requests.stream() + .filter(r -> r.path().contains("/tickers/")) + .reduce((a, b) -> b) + .orElseThrow(); + assertEquals("Bearer jwt-2", retried.authorization()); + } + + @Test + void refreshOn401FailsWhenRetryAlso401() { + tokenResponses.add(jwt("jwt-1", "free")); + tokenResponses.add(jwt("jwt-2", "free")); + tickerStatuses.add(401); + tickerStatuses.add(401); + + AuthenticatedClient session = QTSurfer.auth("ak", opts()); + assertThrows(QTSDownloadError.class, + () -> session.tickers("binance", "BTC", "USDT", "2026-01-15T10")); + assertEquals(2, tokenCalls.get()); + assertEquals(2, tickerCalls.get()); + } + + @Test + void non401ErrorsAreNotRetried() { + tokenResponses.add(jwt("jwt-1", "free")); + tickerStatuses.add(404); + + AuthenticatedClient session = QTSurfer.auth("ak", opts()); + assertThrows(QTSDownloadError.class, + () -> session.tickers("binance", "BTC", "USDT", "2026-01-15T10")); + // Only the initial mint — no refresh. + assertEquals(1, tokenCalls.get()); + assertEquals(1, tickerCalls.get()); + } + + @Test + void clearWipesTheCachedTokenAndStore() { + tokenResponses.add(jwt("jwt-c", "free")); + AtomicReference stored = new AtomicReference<>(); + TokenStore store = new TokenStore() { + @Override public AuthTokenResponse load() { return null; } + @Override public void save(AuthTokenResponse t) { stored.set(t); } + @Override public void clear() { stored.set(null); } + }; + AuthenticatedClient session = QTSurfer.auth("ak", + AuthOptions.builder().baseUrl(baseUrl).store(store).build()); + assertNotNull(session.token()); + assertNotNull(stored.get()); + + session.clear(); + assertNull(session.token()); + assertNull(stored.get()); + } +} diff --git a/src/test/java/com/qtsurfer/api/sdk/integration/AuthHelperIntegrationTest.java b/src/test/java/com/qtsurfer/api/sdk/integration/AuthHelperIntegrationTest.java new file mode 100644 index 0000000..c8d6193 --- /dev/null +++ b/src/test/java/com/qtsurfer/api/sdk/integration/AuthHelperIntegrationTest.java @@ -0,0 +1,126 @@ +package com.qtsurfer.api.sdk.integration; + +import com.qtsurfer.api.client.model.AuthTokenResponse; +import com.qtsurfer.api.sdk.QTSurfer; +import com.qtsurfer.api.sdk.auth.AuthOptions; +import com.qtsurfer.api.sdk.auth.AuthenticatedClient; +import com.qtsurfer.api.sdk.auth.TokenStore; +import com.qtsurfer.api.sdk.errors.QTSAuthError; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +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.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Offline integration test for the {@code auth(apikey)} helper. + * + *

Drives the helper through the public {@code QTSurfer.auth(...)} entry + * point against a real {@link HttpServer} on a free port — exercising the + * full ApiClient stack (JDK {@code HttpClient}, request interceptors, + * Jackson decoding) without ever leaving the test JVM. + * + *

Mirrors the suffix convention of {@code BacktestIntegrationTest} so + * Surefire's {@code *IntegrationTest} include picks it up, but unlike the + * latter it does not require {@code JWT_API_TOKEN}. + */ +class AuthHelperIntegrationTest { + + private HttpServer server; + private String baseUrl; + private final List tokenResponses = new ArrayList<>(); + private final AtomicReference lastApikey = new AtomicReference<>(); + + @BeforeEach + void start() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + String path = exchange.getRequestURI().getPath(); + byte[] body; + int status; + if (path.endsWith("/auth/token")) { + lastApikey.set(exchange.getRequestHeaders().getFirst("X-API-Key")); + if (tokenResponses.isEmpty()) { + status = 500; + body = "{}".getBytes(StandardCharsets.UTF_8); + } else { + String resp = tokenResponses.remove(0); + if (resp.startsWith("401:")) { + status = 401; + body = resp.substring(4).getBytes(StandardCharsets.UTF_8); + } else { + status = 200; + body = resp.getBytes(StandardCharsets.UTF_8); + } + } + } else { + status = 404; + body = new byte[0]; + } + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, body.length == 0 ? -1 : body.length); + try (var os = exchange.getResponseBody()) { + if (body.length > 0) os.write(body); + } + }); + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/v1"; + } + + @AfterEach + void stop() { + server.stop(0); + } + + @Test + void endToEnd_mintAndExposeToken() { + tokenResponses.add( + "{\"access_token\":\"jwt-int\",\"token_type\":\"Bearer\"," + + "\"expires_in\":3600,\"tier\":\"pro\"}"); + + AuthenticatedClient session = QTSurfer.auth("ak_int", + AuthOptions.builder().baseUrl(baseUrl).build()); + + AuthTokenResponse token = session.token(); + assertNotNull(token); + assertEquals("jwt-int", token.getAccessToken()); + assertEquals(AuthTokenResponse.TierEnum.PRO, token.getTier()); + assertEquals("ak_int", lastApikey.get()); + } + + @Test + void endToEnd_tokenStorePluginReceivesSavedToken() { + tokenResponses.add( + "{\"access_token\":\"jwt-store\",\"token_type\":\"Bearer\"," + + "\"expires_in\":3600,\"tier\":\"free\"}"); + + List saved = new ArrayList<>(); + TokenStore store = new TokenStore() { + @Override public AuthTokenResponse load() { return null; } + @Override public void save(AuthTokenResponse t) { saved.add(t); } + @Override public void clear() { saved.clear(); } + }; + + QTSurfer.auth("ak", AuthOptions.builder().baseUrl(baseUrl).store(store).build()); + assertEquals(1, saved.size()); + assertEquals("jwt-store", saved.get(0).getAccessToken()); + } + + @Test + void endToEnd_mint401SurfacesAsQtsAuthError() { + tokenResponses.add("401:{\"code\":\"invalid_apikey\",\"message\":\"x\"}"); + assertThrows(QTSAuthError.class, + () -> QTSurfer.auth("ak_bad", + AuthOptions.builder().baseUrl(baseUrl).build())); + } +}