From 1639e21fab0a6f5ab90be95730eab8d6d1d9d5b3 Mon Sep 17 00:00:00 2001 From: Tomasz Dobrowolski Date: Wed, 26 Aug 2026 13:39:12 +0300 Subject: [PATCH] fix: bare-array endpoints lost their rows and their envelope Both clients decoded every response into a shape that cannot hold a JSON array, so the one endpoint that returns an array was broken in different ways in each language - and in both, provenance was unreachable. Go discarded the array silently. handle() unmarshalled into map[string]interface{} and dropped the resulting error, so callers got an empty map that looked like a successful empty result. OptionQuoteTyped then re-encoded that empty map and returned a single zero-valued quote. An unfiltered live SPY call now returns 6,407 quotes; it previously returned one empty one, with no error anywhere. Java failed loudly but opaquely. handleResponse called getAsJsonObject() on every 200, which throws IllegalStateException on an array. The javadoc had been documenting a workaround - "prefer the parameterised overload" - rather than the defect. The untyped accessor now raises a FlashAlphaException naming the accessor that works. Both gain a *WithMetadata accessor. An array body has nowhere to carry an envelope, so the API sends provenance in the X-Data-As-Of and X-Endpoint-Version headers, and returning the parsed body alone threw that away. OptionQuoteWithMetadata / optionQuoteWithMetadata return the rows and the envelope together, which is the only way to reach provenance for such an endpoint. A malformed or absent header leaves the provenance nil rather than failing the call. Provenance is diagnostic, and an upstream header change should never turn a good response into an error. The refactors are deliberately narrow: Go's handle() now delegates to a new handleRaw so every status and error branch stays in one place, and Java gains a private getRaw that keeps the whole response. Object-bodied endpoints are untouched in both. Nine tests, all offline against a stub server, pin the parts that were silently wrong: the rows survive, the envelope parses out of the headers, a bad header does not fail the call, the single-object shape a filtered call returns still decodes, and Java's untyped accessor gives an actionable message. Verified live as well - 6,407 quotes with the envelope present on both. Go 1.4.0, Java 1.4.0. --- CHANGELOG.md | 23 ++++ README.md | 24 +++- pom.xml | 2 +- .../java/com/flashalpha/FlashAlphaClient.java | 87 ++++++++++++++- .../java/com/flashalpha/OptionQuotes.java | 24 ++++ .../java/com/flashalpha/ResponseMeta.java | 48 ++++++++ .../com/flashalpha/ArrayEnvelopeTest.java | 104 ++++++++++++++++++ .../com/flashalpha/ResponseEnvelopeTest.java | 4 +- 8 files changed, 306 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/flashalpha/OptionQuotes.java create mode 100644 src/main/java/com/flashalpha/ResponseMeta.java create mode 100644 src/test/java/com/flashalpha/ArrayEnvelopeTest.java 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. */