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); + } +}