diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d0af13..0ca0420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to the FlashAlpha Java SDK are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## 1.4.0 - 2026-08-26 + +### Fixed +- **Bare-array endpoints no longer throw.** `handleResponse` called `getAsJsonObject()` + on every 200, which raises `IllegalStateException` on a JSON array - so an unfiltered + `optionQuote` call failed with an opaque cast error. The javadoc had been documenting + a workaround rather than the fix. The untyped accessor now raises a + `FlashAlphaException` naming the accessor to use instead. + +### Added +- **`optionQuoteWithMetadata`** returns an `OptionQuotes` holder with the full chain and + a `ResponseMeta` carrying `endpointVersion` and `dataAsOf` - 6,407 quotes on a live + unfiltered SPY call, where the previous call threw. Array bodies have nowhere to hold + an envelope, so the API sends provenance in the `X-Data-As-Of` and `X-Endpoint-Version` + headers; this is the only way to reach it for such an endpoint. +- `ResponseMeta` and `OptionQuotes`. A malformed or absent header leaves `dataAsOf` null + rather than failing the call - provenance is diagnostic, and losing it should never + turn a good response into an error. + +### Notes +- A new private `getRaw` keeps the whole response so both the array body and the headers + survive; object-bodied endpoints are unchanged. + ## 1.3.0 - 2026-08-25 ### Added diff --git a/README.md b/README.md index a2568dd..d9ef784 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,8 @@ public class Example { Every successful JSON-object response carries `data_as_of`, reporting when each upstream feed last delivered to the node that answered, plus `endpoint_version` identifying the -deployment that produced it. That is every method on this client except the handful that -return a bare JSON array - see the note at the end of this section. +deployment that produced it. The handful of endpoints that return a bare JSON array carry +it in response headers instead - see the note at the end of this section. ```java GexResponse gex = client.gexTyped("SPY"); @@ -139,9 +139,23 @@ null and still parse. A few endpoints return a bare JSON array, which has nowhere to put an envelope in the body. The API sends the same information in the `X-Data-As-Of` and `X-Endpoint-Version` -response headers instead - but this client returns the parsed body only and does not -surface response headers, so the envelope is **not reachable through those methods**. -Call the HTTP endpoint directly if you need provenance for one of them. +response headers instead, and the `*WithMetadata` accessors return both together: + +```java +OptionQuotes result = client.optionQuoteWithMetadata("SPY"); + +result.quotes.size(); // 6407 - the whole chain +result.meta.dataAsOf.equityOptionsFeed; // provenance, read from the response headers +result.meta.endpointVersion; +``` + +The untyped `optionQuote(...)` returns a `JsonObject`, which cannot represent an array; +calling it on this endpoint raises a `FlashAlphaException` pointing here rather than an +opaque cast failure. + +A malformed or absent `X-Data-As-Of` leaves `result.meta.dataAsOf` null rather than +failing the call - provenance is diagnostic, and losing it should not turn a good +response into an error. Full reference: and the methodology whitepaper at . diff --git a/pom.xml b/pom.xml index 0d852eb..af095d9 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ com.flashalpha flashalpha - 1.3.0 + 1.4.0 jar FlashAlpha Java SDK diff --git a/src/main/java/com/flashalpha/FlashAlphaClient.java b/src/main/java/com/flashalpha/FlashAlphaClient.java index 4ac9941..2e70fb7 100644 --- a/src/main/java/com/flashalpha/FlashAlphaClient.java +++ b/src/main/java/com/flashalpha/FlashAlphaClient.java @@ -1,6 +1,7 @@ package com.flashalpha; import com.google.gson.Gson; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -13,6 +14,8 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.LinkedHashMap; +import java.util.ArrayList; +import java.util.List; import java.util.Map; /** @@ -89,6 +92,17 @@ private JsonObject get(String path) { } private JsonObject get(String path, Map params) { + return handleResponse(getRaw(path, params)); + } + + /** + * GET that returns the whole response rather than a parsed object. + * + *

Needed because {@code handleResponse} calls {@code getAsJsonObject()}, which + * throws on a bare JSON array - and because array-bodied endpoints carry their + * envelope in headers, which a parsed body discards. + */ + private HttpResponse getRaw(String path, Map params) { String url = baseUrl + path; if (params != null && !params.isEmpty()) { url = url + "?" + buildQuery(params); @@ -110,7 +124,7 @@ private JsonObject get(String path, Map params) { throw new FlashAlphaException("HTTP request failed: " + e.getMessage(), 0, null); } - return handleResponse(response); + return response; } private JsonObject post(String path, Object body) { @@ -142,7 +156,15 @@ private JsonObject handleResponse(HttpResponse response) { String body = response.body(); if (status == 200) { - return JsonParser.parseString(body).getAsJsonObject(); + JsonElement parsed = JsonParser.parseString(body); + if (parsed.isJsonArray()) { + throw new FlashAlphaException( + "This endpoint returned a JSON array, which cannot be represented as a " + + "JsonObject. Use the *WithMetadata accessor (for example " + + "optionQuoteWithMetadata) to get the rows and the response envelope.", + status, null); + } + return parsed.getAsJsonObject(); } // Try to parse error body as JSON @@ -287,6 +309,67 @@ public OptionQuoteResponse optionQuoteTyped(String ticker, String expiry, Double * * @param ticker Stock ticker symbol. */ + /** + * Every option quote for a ticker, together with the response envelope. + * + *

Unfiltered, {@code /optionquote} returns a bare JSON array. That shape cannot be + * carried on a single response model, and an array body has nowhere to hold an + * envelope - the API sends provenance in the {@code X-Data-As-Of} and + * {@code X-Endpoint-Version} headers instead. This method is the only way to get both. + * + * @param ticker Stock ticker symbol. + * @return the quotes plus their provenance. + */ + public OptionQuotes optionQuoteWithMetadata(String ticker) { + return optionQuoteWithMetadata(ticker, null, null, null); + } + + /** + * Filtered option quotes together with the response envelope. + * + *

With filters the endpoint returns a single object rather than an array; both + * shapes are accepted here and normalised to a list. + * + * @param ticker Stock ticker symbol. + * @param expiry Optional expiry filter (YYYY-MM-DD). + * @param strike Optional strike filter. + * @param type Optional contract type ("call" / "put"). + * @return the quotes plus their provenance. + */ + public OptionQuotes optionQuoteWithMetadata(String ticker, String expiry, Double strike, String type) { + Map params = new LinkedHashMap<>(); + if (expiry != null) params.put("expiry", expiry); + if (strike != null) params.put("strike", String.valueOf(strike)); + if (type != null) params.put("type", type); + + HttpResponse response = getRaw("/optionquote/" + ticker, params); + ResponseMeta meta = ResponseMeta.from(response.headers(), gson); + + if (response.statusCode() != 200) { + handleResponse(response); // reuse the shared error mapping + } + + JsonElement parsed = JsonParser.parseString(response.body()); + List quotes = new ArrayList<>(); + + if (parsed.isJsonArray()) { + for (JsonElement e : parsed.getAsJsonArray()) { + quotes.add(gson.fromJson(e, OptionQuoteResponse.class)); + } + } else if (parsed.isJsonObject()) { + JsonObject obj = parsed.getAsJsonObject(); + if (obj.has("quotes") && obj.get("quotes").isJsonArray()) { + for (JsonElement e : obj.getAsJsonArray("quotes")) { + quotes.add(gson.fromJson(e, OptionQuoteResponse.class)); + } + } else { + quotes.add(gson.fromJson(obj, OptionQuoteResponse.class)); + } + } + + return new OptionQuotes(quotes, meta); + } + public OptionQuoteResponse optionQuoteTyped(String ticker) { return optionQuoteTyped(ticker, null, null, null); } diff --git a/src/main/java/com/flashalpha/OptionQuotes.java b/src/main/java/com/flashalpha/OptionQuotes.java new file mode 100644 index 0000000..c1b5da6 --- /dev/null +++ b/src/main/java/com/flashalpha/OptionQuotes.java @@ -0,0 +1,24 @@ +package com.flashalpha; + +import java.util.List; + +/** + * The result of an unfiltered option-quote call: the chain plus its envelope. + * + *

{@code /optionquote} returns a bare JSON array when no expiry/strike/type filter is + * given, so the quotes cannot be carried on a response model and the envelope cannot be + * carried in the body. This pairs the two. + */ +public final class OptionQuotes { + + /** Every quote returned. Empty rather than null when the chain is empty. */ + public final List quotes; + + /** Per-feed provenance, read from the response headers. */ + public final ResponseMeta meta; + + OptionQuotes(List quotes, ResponseMeta meta) { + this.quotes = quotes; + this.meta = meta; + } +} diff --git a/src/main/java/com/flashalpha/ResponseMeta.java b/src/main/java/com/flashalpha/ResponseMeta.java new file mode 100644 index 0000000..8eea2f0 --- /dev/null +++ b/src/main/java/com/flashalpha/ResponseMeta.java @@ -0,0 +1,48 @@ +package com.flashalpha; + +import com.google.gson.Gson; + +import java.net.http.HttpHeaders; + +/** + * The envelope for endpoints that return a bare JSON array. + * + *

Those responses have nowhere to put an envelope in the body, so the API sends it in + * the {@code X-Data-As-Of} and {@code X-Endpoint-Version} headers instead. The + * {@code *WithMetadata} accessors return this alongside the decoded body, which is the + * only way to reach provenance for such an endpoint. + */ +public final class ResponseMeta { + + /** Identifies the deployment that produced the response. Null if the header was absent. */ + public final String endpointVersion; + + /** Per-feed freshness, or null if the header was absent or unparseable. */ + public final DataAsOf dataAsOf; + + ResponseMeta(String endpointVersion, DataAsOf dataAsOf) { + this.endpointVersion = endpointVersion; + this.dataAsOf = dataAsOf; + } + + /** + * Reads the envelope headers. + * + *

A malformed or absent {@code X-Data-As-Of} leaves {@link #dataAsOf} null rather + * than failing the call: provenance is diagnostic, and losing it should never turn a + * good response into an error. + */ + static ResponseMeta from(HttpHeaders headers, Gson gson) { + String version = headers.firstValue("X-Endpoint-Version").orElse(null); + DataAsOf asOf = null; + String raw = headers.firstValue("X-Data-As-Of").orElse(null); + if (raw != null && !raw.isBlank()) { + try { + asOf = gson.fromJson(raw, DataAsOf.class); + } catch (RuntimeException ignored) { + // leave null + } + } + return new ResponseMeta(version, asOf); + } +} diff --git a/src/test/java/com/flashalpha/ArrayEnvelopeTest.java b/src/test/java/com/flashalpha/ArrayEnvelopeTest.java new file mode 100644 index 0000000..9370d4f --- /dev/null +++ b/src/test/java/com/flashalpha/ArrayEnvelopeTest.java @@ -0,0 +1,104 @@ +package com.flashalpha; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * A bare JSON array is the one body shape that cannot carry an envelope, so the API moves + * it to headers. These tests pin both halves: the rows survive decoding, and the envelope + * is still reachable. + */ +public class ArrayEnvelopeTest { + + private static final String AS_OF = "{\"node\":\"fa2\",\"equity_feed\":\"2026-08-25T18:48:58.204Z\"," + + "\"equity_options_feed\":\"2026-08-25T18:48:57.900Z\",\"index_feed\":null," + + "\"index_options_feed\":null,\"futures_feed\":null,\"futures_options_feed\":null," + + "\"flow_feed\":null,\"oi_feed\":\"2026-08-24T20:00:00.000Z\",\"macro_feed\":null}"; + + private MockWebServer server; + private FlashAlphaClient client; + + @Before + public void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + client = new FlashAlphaClient("k", server.url("/").toString().replaceAll("/$", "")); + } + + @After + public void tearDown() throws Exception { + server.shutdown(); + } + + private void enqueueArray() { + server.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setHeader("X-Endpoint-Version", "2026.08.25") + .setHeader("X-Data-As-Of", AS_OF) + .setBody("[{\"strike\":500,\"type\":\"call\"},{\"strike\":505,\"type\":\"put\"}]")); + } + + /** Regression: this shape used to throw IllegalStateException out of getAsJsonObject(). */ + @Test + public void arrayBodyKeepsItsRows() { + enqueueArray(); + OptionQuotes result = client.optionQuoteWithMetadata("SPY"); + assertEquals(2, result.quotes.size()); + } + + @Test + public void arrayBodyExposesTheEnvelopeFromHeaders() { + enqueueArray(); + OptionQuotes result = client.optionQuoteWithMetadata("SPY"); + + assertEquals("2026.08.25", result.meta.endpointVersion); + assertNotNull("data_as_of header was not parsed", result.meta.dataAsOf); + assertEquals("fa2", result.meta.dataAsOf.node); + assertEquals("2026-08-24T20:00:00.000Z", result.meta.dataAsOf.oiFeed); + assertNull("unseen feed should stay null", result.meta.dataAsOf.indexFeed); + } + + /** + * Provenance is diagnostic: a malformed header must not turn a good response into an + * error, or a header change upstream would break every array call. + */ + @Test + public void malformedEnvelopeHeaderDoesNotFailTheCall() { + server.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("X-Data-As-Of", "not json") + .setBody("[{\"strike\":500}]")); + + OptionQuotes result = client.optionQuoteWithMetadata("SPY"); + assertEquals(1, result.quotes.size()); + assertNull(result.meta.dataAsOf); + } + + /** The filtered call returns a single object; both shapes normalise to a list. */ + @Test + public void singleObjectShapeStillDecodes() { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"strike\":500,\"type\":\"call\"}")); + + OptionQuotes result = client.optionQuoteWithMetadata("SPY", "2026-08-28", 500.0, "call"); + assertEquals(1, result.quotes.size()); + } + + /** The untyped accessor cannot represent an array, so it must say so usefully. */ + @Test + public void untypedAccessorFailsWithAnActionableMessage() { + enqueueArray(); + try { + client.optionQuote("SPY"); + fail("expected a clear failure"); + } catch (FlashAlphaException e) { + assertTrue(e.getMessage().contains("WithMetadata")); + } + } +} diff --git a/src/test/java/com/flashalpha/ResponseEnvelopeTest.java b/src/test/java/com/flashalpha/ResponseEnvelopeTest.java index 1c41135..519cc37 100644 --- a/src/test/java/com/flashalpha/ResponseEnvelopeTest.java +++ b/src/test/java/com/flashalpha/ResponseEnvelopeTest.java @@ -31,7 +31,7 @@ public class ResponseEnvelopeTest { + "\"futures_feed\":null," + "\"futures_options_feed\":null," + "\"flow_feed\":\"2026-08-25T18:48:55.100Z\"," - + "\"oi_feed\":\"2026-08-22T20:00:00.000Z\"," + + "\"oi_feed\":\"2026-08-24T20:00:00.000Z\"," + "\"macro_feed\":\"2026-08-25T18:45:00.000Z\"" + "}}"; @@ -74,7 +74,7 @@ public void unseenFeedsStayNull() { public void settledOpenInterestTrailsUnmodified() { GexResponse gex = gson.fromJson(BODY, GexResponse.class); - assertEquals("2026-08-22T20:00:00.000Z", gex.dataAsOf.oiFeed); + assertEquals("2026-08-24T20:00:00.000Z", gex.dataAsOf.oiFeed); } /** Responses predating the envelope must still parse; both members stay null. */