From 85b5cf74fc97a6147be5c443c84ab3aea97b89d0 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Fri, 24 Jul 2026 11:44:28 +0100 Subject: [PATCH] fix: emit additionalProperties as query params on streaming connect Fixes #83. The streaming connect() builders build the upgrade URL only from the typed options and never emit additionalProperties, so unmodeled query params set via the builder's additionalProperty escape hatch -- e.g. no_delay -- were silently dropped. connect() also accepts no RequestOptions, so there was no working way to send an unmodeled query param on a streaming connection at all. Emit additionalProperties as query params in connect(), routed through QueryStringMapper with repeats enabled so non-string values and lists serialize the same way the REST path does. ConnectOptions is request-only and never deserialized, so the map only ever holds what the caller set. Applied to all four connect clients -- listen v1, listen v2, speak v1, speak v2. speak v1 was not previously frozen, so it is added to .fernignore. The durable fix belongs upstream in the Fern generator WebSocket template, which should emit additionalProperties on the connect path. --- .fernignore | 26 ++-- .../v1/websocket/V1WebSocketClient.java | 11 ++ .../v2/websocket/V2WebSocketClient.java | 11 ++ .../speak/v1/websocket/V1WebSocketClient.java | 12 ++ .../speak/v2/websocket/V2WebSocketClient.java | 11 ++ ...StreamingAdditionalPropertiesWireTest.java | 141 ++++++++++++++++++ 6 files changed, 202 insertions(+), 10 deletions(-) create mode 100644 src/test/java/com/deepgram/StreamingAdditionalPropertiesWireTest.java diff --git a/.fernignore b/.fernignore index be7ceed..ce1762c 100644 --- a/.fernignore +++ b/.fernignore @@ -29,20 +29,26 @@ src/main/java/com/deepgram/core/ReconnectingWebSocketListener.java # server control frames look fatal to deployed clients. Patched to a no-op (the raw # frame is still delivered via onMessage(String)); mirrors the JS/Python SDKs. Re-apply # after regen. Unfreeze once the generator stops treating unknown frames as errors. -# NOTE: both the speak v2 and listen v2 clients below also carry the multi-value query param fix -# documented lower down (keep them frozen until BOTH that fix and this one are upstreamed). +# NOTE: the listen v2 and speak v2 clients below also carry the streaming query-param patches +# (multi-value serialization + the additionalProperties escape hatch) documented lower down — +# keep them frozen until those fixes are upstreamed too. src/main/java/com/deepgram/resources/speak/v2/websocket/V2WebSocketClient.java src/main/java/com/deepgram/resources/listen/v2/websocket/V2WebSocketClient.java -# Multi-value query param serialization fix: the generated streaming clients serialize every -# array-valued query param (listen: keyterm, keywords, replace, search, tag, extra, language_hint; -# speak: tag) with String.valueOf(union.get()), which stringifies a List into a single -# param (keyterm=[a, b]) instead of repeated params (keyterm=a&keyterm=b) — the server treats the -# former as one nonsense term. Patched to route these through QueryStringMapper(arraysAsRepeats= -# true), matching the REST path. The v2 listen and speak clients are already frozen above; freeze -# the v1 listen client here too so the fix survives regen. Re-apply after regen; unfreeze once the -# generator serializes streaming array query params as repeats (tracked as an upstream Fern request). +# Streaming query-param patches on the generated WS connect() builders. Two fixes: +# (1) Multi-value serialization: array-valued params (listen: keyterm, keywords, replace, search, +# tag, extra, language_hint; speak: tag) were serialized with String.valueOf(union.get()), +# collapsing a List into one param (keyterm=[a, b]) instead of repeats (keyterm=a&keyterm=b), +# which the server treats as one nonsense value. +# (2) additionalProperties escape hatch: the builder exposes additionalProperty(key, value) for +# unmodeled params (e.g. no_delay), but connect() never emitted them to the URL, so they were +# silently dropped. +# Both patched to route through QueryStringMapper(arraysAsRepeats=true), matching the REST path. The +# listen v2 and speak v2 clients are frozen above; freeze the listen v1 and speak v1 clients here too +# so the fixes survive regen. Re-apply after regen; unfreeze once the generator emits array params as +# repeats and serializes additionalProperties on the WS connect path (tracked as an upstream Fern request). src/main/java/com/deepgram/resources/listen/v1/websocket/V1WebSocketClient.java +src/main/java/com/deepgram/resources/speak/v1/websocket/V1WebSocketClient.java # Manual equals/hashCode contract fix: Fern generates equals() (all instances equal) but no # hashCode() for fields-less message types, violating the Object contract. We add a consistent diff --git a/src/main/java/com/deepgram/resources/listen/v1/websocket/V1WebSocketClient.java b/src/main/java/com/deepgram/resources/listen/v1/websocket/V1WebSocketClient.java index f0a5ce3..ffa4953 100644 --- a/src/main/java/com/deepgram/resources/listen/v1/websocket/V1WebSocketClient.java +++ b/src/main/java/com/deepgram/resources/listen/v1/websocket/V1WebSocketClient.java @@ -222,6 +222,17 @@ public CompletableFuture connect(V1ConnectOptions options) { urlBuilder.addQueryParameter( "version", String.valueOf(options.getVersion().get())); } + // Escape hatch: emit caller-supplied additionalProperties (e.g. no_delay) as query params. + // The generated template only serializes the typed options and drops these otherwise. + // ConnectOptions is request-only (never deserialized), so this map holds only what the + // caller set via the builder. Routed through QueryStringMapper to match the REST path. + if (options.getAdditionalProperties() != null) { + options.getAdditionalProperties().forEach((key, value) -> { + if (value != null) { + QueryStringMapper.addQueryParameter(urlBuilder, key, value, true); + } + }); + } Request.Builder requestBuilder = new Request.Builder().url(urlBuilder.build()); clientOptions.headers((RequestOptions) null).forEach(requestBuilder::addHeader); final Request request = requestBuilder.build(); diff --git a/src/main/java/com/deepgram/resources/listen/v2/websocket/V2WebSocketClient.java b/src/main/java/com/deepgram/resources/listen/v2/websocket/V2WebSocketClient.java index 4492f8d..f47ee22 100644 --- a/src/main/java/com/deepgram/resources/listen/v2/websocket/V2WebSocketClient.java +++ b/src/main/java/com/deepgram/resources/listen/v2/websocket/V2WebSocketClient.java @@ -154,6 +154,17 @@ public CompletableFuture connect(V2ConnectOptions options) { QueryStringMapper.addQueryParameter( urlBuilder, "tag", options.getTag().get().get(), true); } + // Escape hatch: emit caller-supplied additionalProperties (e.g. no_delay) as query params. + // The generated template only serializes the typed options and drops these otherwise. + // ConnectOptions is request-only (never deserialized), so this map holds only what the + // caller set via the builder. Routed through QueryStringMapper to match the REST path. + if (options.getAdditionalProperties() != null) { + options.getAdditionalProperties().forEach((key, value) -> { + if (value != null) { + QueryStringMapper.addQueryParameter(urlBuilder, key, value, true); + } + }); + } Request.Builder requestBuilder = new Request.Builder().url(urlBuilder.build()); clientOptions.headers((RequestOptions) null).forEach(requestBuilder::addHeader); final Request request = requestBuilder.build(); diff --git a/src/main/java/com/deepgram/resources/speak/v1/websocket/V1WebSocketClient.java b/src/main/java/com/deepgram/resources/speak/v1/websocket/V1WebSocketClient.java index 8af4275..8033a55 100644 --- a/src/main/java/com/deepgram/resources/speak/v1/websocket/V1WebSocketClient.java +++ b/src/main/java/com/deepgram/resources/speak/v1/websocket/V1WebSocketClient.java @@ -6,6 +6,7 @@ import com.deepgram.core.ClientOptions; import com.deepgram.core.DisconnectReason; import com.deepgram.core.ObjectMappers; +import com.deepgram.core.QueryStringMapper; import com.deepgram.core.ReconnectingWebSocketListener; import com.deepgram.core.RequestOptions; import com.deepgram.core.WebSocketReadyState; @@ -122,6 +123,17 @@ public CompletableFuture connect(V1ConnectOptions options) { urlBuilder.addQueryParameter( "speed", String.valueOf(options.getSpeed().get())); } + // Escape hatch: emit caller-supplied additionalProperties (e.g. no_delay) as query params. + // The generated template only serializes the typed options and drops these otherwise. + // ConnectOptions is request-only (never deserialized), so this map holds only what the + // caller set via the builder. Routed through QueryStringMapper to match the REST path. + if (options.getAdditionalProperties() != null) { + options.getAdditionalProperties().forEach((key, value) -> { + if (value != null) { + QueryStringMapper.addQueryParameter(urlBuilder, key, value, true); + } + }); + } Request.Builder requestBuilder = new Request.Builder().url(urlBuilder.build()); clientOptions.headers((RequestOptions) null).forEach(requestBuilder::addHeader); final Request request = requestBuilder.build(); diff --git a/src/main/java/com/deepgram/resources/speak/v2/websocket/V2WebSocketClient.java b/src/main/java/com/deepgram/resources/speak/v2/websocket/V2WebSocketClient.java index 2e55a46..44426c4 100644 --- a/src/main/java/com/deepgram/resources/speak/v2/websocket/V2WebSocketClient.java +++ b/src/main/java/com/deepgram/resources/speak/v2/websocket/V2WebSocketClient.java @@ -132,6 +132,17 @@ public CompletableFuture connect(V2ConnectOptions options) { QueryStringMapper.addQueryParameter( urlBuilder, "tag", options.getTag().get().get(), true); } + // Escape hatch: emit caller-supplied additionalProperties (e.g. no_delay) as query params. + // The generated template only serializes the typed options and drops these otherwise. + // ConnectOptions is request-only (never deserialized), so this map holds only what the + // caller set via the builder. Routed through QueryStringMapper to match the REST path. + if (options.getAdditionalProperties() != null) { + options.getAdditionalProperties().forEach((key, value) -> { + if (value != null) { + QueryStringMapper.addQueryParameter(urlBuilder, key, value, true); + } + }); + } Request.Builder requestBuilder = new Request.Builder().url(urlBuilder.build()); clientOptions.headers((RequestOptions) null).forEach(requestBuilder::addHeader); final Request request = requestBuilder.build(); diff --git a/src/test/java/com/deepgram/StreamingAdditionalPropertiesWireTest.java b/src/test/java/com/deepgram/StreamingAdditionalPropertiesWireTest.java new file mode 100644 index 0000000..7ce1779 --- /dev/null +++ b/src/test/java/com/deepgram/StreamingAdditionalPropertiesWireTest.java @@ -0,0 +1,141 @@ +package com.deepgram; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.deepgram.core.Environment; +import com.deepgram.types.ListenV1Model; +import com.deepgram.types.ListenV2Model; +import com.deepgram.types.SpeakV1Model; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import okhttp3.HttpUrl; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Connect-handshake wire coverage for the {@code additionalProperties} escape hatch on every + * streaming {@code connect(...)} builder (issue #83). + * + *

The generated clients build the upgrade URL only from the typed options and drop + * {@code additionalProperties}, so unmodeled query params (e.g. {@code no_delay}) set via + * {@code .additionalProperty(key, value)} never reached the wire. Guards that they now do, on + * listen v1/v2 and speak v1/v2. Frozen via {@code src/test/} in .fernignore. + */ +class StreamingAdditionalPropertiesWireTest { + private MockWebServer server; + private DeepgramClient client; + + @BeforeEach + void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + String base = server.url("/").toString().replaceAll("/$", ""); + Environment env = Environment.custom() + .base(base) + .production(base) + .agent(base) + .agentRest(base) + .build(); + client = DeepgramClient.builder().apiKey("test").environment(env).build(); + } + + @AfterEach + void tearDown() throws Exception { + server.shutdown(); + } + + private HttpUrl connectAndCaptureUrl(Consumer connect, String expectedPath) throws Exception { + server.enqueue(new MockResponse().withWebSocketUpgrade(new WebSocketListener() { + @Override + public void onOpen(WebSocket webSocket, okhttp3.Response response) { + webSocket.close(1000, null); + } + })); + connect.accept(server); + RecordedRequest request = server.takeRequest(5, TimeUnit.SECONDS); + assertThat(request).as("handshake request was sent").isNotNull(); + HttpUrl url = HttpUrl.parse(server.url("/").scheme() + "://" + request.getHeader("Host") + request.getPath()); + assertThat(url).as("parsed handshake URL").isNotNull(); + assertThat(url.encodedPath()).isEqualTo(expectedPath); + return url; + } + + private void assertEscapeHatchEmitted(HttpUrl url) { + // The unmodeled params reached the wire, with a non-string value serialized correctly... + assertThat(url.queryParameter("no_delay")).isEqualTo("true"); + assertThat(url.queryParameter("custom_key")).isEqualTo("custom_value"); + // ...alongside (not instead of) the typed option. + assertThat(url.queryParameterNames()).contains("model"); + } + + @Test + @DisplayName("listen v1: additionalProperties (no_delay) are emitted as query params") + void listenV1() throws Exception { + HttpUrl url = connectAndCaptureUrl( + s -> client.listen() + .v1() + .v1WebSocket() + .connect(com.deepgram.resources.listen.v1.websocket.V1ConnectOptions.builder() + .model(ListenV1Model.NOVA3) + .additionalProperty("no_delay", true) + .additionalProperty("custom_key", "custom_value") + .build()), + "/v1/listen"); + assertEscapeHatchEmitted(url); + } + + @Test + @DisplayName("listen v2: additionalProperties (no_delay) are emitted as query params") + void listenV2() throws Exception { + HttpUrl url = connectAndCaptureUrl( + s -> client.listen() + .v2() + .v2WebSocket() + .connect(com.deepgram.resources.listen.v2.websocket.V2ConnectOptions.builder() + .model(ListenV2Model.FLUX_GENERAL_EN) + .additionalProperty("no_delay", true) + .additionalProperty("custom_key", "custom_value") + .build()), + "/v2/listen"); + assertEscapeHatchEmitted(url); + } + + @Test + @DisplayName("speak v1: additionalProperties are emitted as query params") + void speakV1() throws Exception { + HttpUrl url = connectAndCaptureUrl( + s -> client.speak() + .v1() + .v1WebSocket() + .connect(com.deepgram.resources.speak.v1.websocket.V1ConnectOptions.builder() + .model(SpeakV1Model.AURA2HERA_EN) + .additionalProperty("no_delay", true) + .additionalProperty("custom_key", "custom_value") + .build()), + "/v1/speak"); + assertEscapeHatchEmitted(url); + } + + @Test + @DisplayName("speak v2: additionalProperties are emitted as query params") + void speakV2() throws Exception { + HttpUrl url = connectAndCaptureUrl( + s -> client.speak() + .v2() + .v2WebSocket() + .connect(com.deepgram.resources.speak.v2.websocket.V2ConnectOptions.builder() + .model("aura-2-thalia-en") + .additionalProperty("no_delay", true) + .additionalProperty("custom_key", "custom_value") + .build()), + "/v2/speak"); + assertEscapeHatchEmitted(url); + } +}