Skip to content
Merged
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
26 changes: 16 additions & 10 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,17 @@ public CompletableFuture<Void> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,17 @@ public CompletableFuture<Void> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -122,6 +123,17 @@ public CompletableFuture<Void> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,17 @@ public CompletableFuture<Void> 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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>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<MockWebServer> 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);
}
}
Loading