Skip to content
Open
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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: <https://flashalpha.com/docs/lab-api-overview#response-envelope> and the
methodology whitepaper at <https://flashalpha.com/methodology#freshness-reporting>.
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

<groupId>com.flashalpha</groupId>
<artifactId>flashalpha</artifactId>
<version>1.3.0</version>
<version>1.4.0</version>
<packaging>jar</packaging>

<name>FlashAlpha Java SDK</name>
Expand Down
87 changes: 85 additions & 2 deletions src/main/java/com/flashalpha/FlashAlphaClient.java
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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;

/**
Expand Down Expand Up @@ -89,6 +92,17 @@ private JsonObject get(String path) {
}

private JsonObject get(String path, Map<String, String> params) {
return handleResponse(getRaw(path, params));
}

/**
* GET that returns the whole response rather than a parsed object.
*
* <p>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<String> getRaw(String path, Map<String, String> params) {
String url = baseUrl + path;
if (params != null && !params.isEmpty()) {
url = url + "?" + buildQuery(params);
Expand All @@ -110,7 +124,7 @@ private JsonObject get(String path, Map<String, String> params) {
throw new FlashAlphaException("HTTP request failed: " + e.getMessage(), 0, null);
}

return handleResponse(response);
return response;
}

private JsonObject post(String path, Object body) {
Expand Down Expand Up @@ -142,7 +156,15 @@ private JsonObject handleResponse(HttpResponse<String> 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
Expand Down Expand Up @@ -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.
*
* <p>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.
*
* <p>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<String, String> 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<String> 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<OptionQuoteResponse> 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);
}
Expand Down
24 changes: 24 additions & 0 deletions src/main/java/com/flashalpha/OptionQuotes.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.flashalpha;

import java.util.List;

/**
* The result of an unfiltered option-quote call: the chain plus its envelope.
*
* <p>{@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<OptionQuoteResponse> quotes;

/** Per-feed provenance, read from the response headers. */
public final ResponseMeta meta;

OptionQuotes(List<OptionQuoteResponse> quotes, ResponseMeta meta) {
this.quotes = quotes;
this.meta = meta;
}
}
48 changes: 48 additions & 0 deletions src/main/java/com/flashalpha/ResponseMeta.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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);
}
}
104 changes: 104 additions & 0 deletions src/test/java/com/flashalpha/ArrayEnvelopeTest.java
Original file line number Diff line number Diff line change
@@ -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"));
}
}
}
Loading