From 41b406de4c8b01238e785c2a0318b1f4d76da2dd Mon Sep 17 00:00:00 2001 From: James Milligan Date: Fri, 3 Jul 2026 12:53:33 -0400 Subject: [PATCH 1/5] Add configurable connect and request timeouts --- .../nightowl/sonos/api/SonosApiClient.java | 11 +++-- .../sonos/api/SonosApiConfiguration.java | 43 +++++++++++++++++++ .../sonos/api/resource/AuthorizeResource.java | 10 +++-- .../sonos/api/resource/BaseResource.java | 21 ++++++++- .../sonos/api/resource/BaseResourceTest.java | 18 ++++++++ 5 files changed, 96 insertions(+), 7 deletions(-) diff --git a/src/main/java/engineer/nightowl/sonos/api/SonosApiClient.java b/src/main/java/engineer/nightowl/sonos/api/SonosApiClient.java index aa812ce..2e3115d 100644 --- a/src/main/java/engineer/nightowl/sonos/api/SonosApiClient.java +++ b/src/main/java/engineer/nightowl/sonos/api/SonosApiClient.java @@ -111,9 +111,14 @@ public String getUserAgent() private HttpClient generateHttpClient() { logger.debug("Using default HttpClient"); - return HttpClient.newBuilder() - .followRedirects(HttpClient.Redirect.NORMAL) - .build(); + final HttpClient.Builder builder = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL); + final var connectTimeout = configuration.getConnectTimeout(); + if (connectTimeout != null && !connectTimeout.isZero() && !connectTimeout.isNegative()) + { + builder.connectTimeout(connectTimeout); + } + return builder.build(); } /** diff --git a/src/main/java/engineer/nightowl/sonos/api/SonosApiConfiguration.java b/src/main/java/engineer/nightowl/sonos/api/SonosApiConfiguration.java index 71233c7..f45da47 100644 --- a/src/main/java/engineer/nightowl/sonos/api/SonosApiConfiguration.java +++ b/src/main/java/engineer/nightowl/sonos/api/SonosApiConfiguration.java @@ -4,6 +4,7 @@ import org.apache.commons.lang3.builder.HashCodeBuilder; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Base64; /** @@ -20,6 +21,8 @@ public class SonosApiConfiguration private String authBaseUrl; private String controlBaseUrl; private Boolean clientSideValidationEnabled; + private Duration connectTimeout; + private Duration requestTimeout; /** *

Constructor for SonosApiConfiguration.

@@ -124,12 +127,46 @@ public void setClientSideValidationEnabled(Boolean clientSideValidationEnabled) this.clientSideValidationEnabled = clientSideValidationEnabled; } + /** + *

Getter for the field connectTimeout - how long to wait when establishing a + * connection to Sonos.

+ * + * @return the connect timeout + */ + public Duration getConnectTimeout() + { + return connectTimeout; + } + + public void setConnectTimeout(final Duration connectTimeout) + { + this.connectTimeout = connectTimeout; + } + + /** + *

Getter for the field requestTimeout - how long to wait for a response before a + * single request is aborted.

+ * + * @return the request timeout + */ + public Duration getRequestTimeout() + { + return requestTimeout; + } + + public void setRequestTimeout(final Duration requestTimeout) + { + this.requestTimeout = requestTimeout; + } + public void loadDefaults() { setAuthBaseUrl("api.sonos.com"); setControlBaseUrl("api.ws.sonos.com/control/api"); setClientSideValidationEnabled(Boolean.TRUE); + setConnectTimeout(Duration.ofSeconds(10)); + setRequestTimeout(Duration.ofSeconds(30)); } /** @@ -154,6 +191,8 @@ public String toString() ", authBaseUrl='" + authBaseUrl + '\'' + ", controlBaseUrl='" + controlBaseUrl + '\'' + ", clientSideValidationEnabled=" + clientSideValidationEnabled + + ", connectTimeout=" + connectTimeout + + ", requestTimeout=" + requestTimeout + '}'; } @@ -185,6 +224,8 @@ public boolean equals(Object o) .append(authBaseUrl, that.authBaseUrl) .append(controlBaseUrl, that.controlBaseUrl) .append(clientSideValidationEnabled, that.clientSideValidationEnabled) + .append(connectTimeout, that.connectTimeout) + .append(requestTimeout, that.requestTimeout) .isEquals(); } @@ -198,6 +239,8 @@ public int hashCode() .append(authBaseUrl) .append(controlBaseUrl) .append(clientSideValidationEnabled) + .append(connectTimeout) + .append(requestTimeout) .toHashCode(); } } diff --git a/src/main/java/engineer/nightowl/sonos/api/resource/AuthorizeResource.java b/src/main/java/engineer/nightowl/sonos/api/resource/AuthorizeResource.java index aa9965f..98a6863 100644 --- a/src/main/java/engineer/nightowl/sonos/api/resource/AuthorizeResource.java +++ b/src/main/java/engineer/nightowl/sonos/api/resource/AuthorizeResource.java @@ -144,12 +144,16 @@ private HttpRequest buildOauthAccessRequest(final List throw new SonosApiClientException("Invalid URI built", e); } - return HttpRequest.newBuilder(uri) + final HttpRequest.Builder builder = HttpRequest.newBuilder(uri) .setHeader("Authorization", configuration.getAuthorizationHeader()) .setHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") .setHeader("User-Agent", apiClient.getUserAgent()) - .POST(HttpRequest.BodyPublishers.ofString(encodeParameters(postParameters), StandardCharsets.UTF_8)) - .build(); + .POST(HttpRequest.BodyPublishers.ofString(encodeParameters(postParameters), StandardCharsets.UTF_8)); + if (isPositive(configuration.getRequestTimeout())) + { + builder.timeout(configuration.getRequestTimeout()); + } + return builder.build(); } /** diff --git a/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java b/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java index b6572a9..77ce4d1 100644 --- a/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java +++ b/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java @@ -19,6 +19,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Optional; /** @@ -282,9 +283,14 @@ HttpRequest.Builder getStandardRequest(final String token, final String path) th throw new SonosApiClientException("Invalid URI built", e); } - return HttpRequest.newBuilder(uri) + final HttpRequest.Builder builder = HttpRequest.newBuilder(uri) .setHeader("Authorization", String.format("Bearer %s", token)) .setHeader("User-Agent", apiClient.getUserAgent()); + if (isPositive(configuration.getRequestTimeout())) + { + builder.timeout(configuration.getRequestTimeout()); + } + return builder; } /** @@ -313,6 +319,19 @@ HttpRequest getDeleteRequest(final String token, final String path) throws Sonos return getStandardRequest(token, path).DELETE().build(); } + /** + * A timeout only applies if it is present and strictly positive - {@link HttpRequest.Builder#timeout} + * and {@link java.net.http.HttpClient.Builder#connectTimeout} both reject zero/negative durations, so a + * non-positive configured value is treated as "no timeout" rather than an error. + * + * @param timeout the configured timeout, possibly null + * @return true if the timeout should be applied + */ + static boolean isPositive(final Duration timeout) + { + return timeout != null && !timeout.isZero() && !timeout.isNegative(); + } + void validateNotNull(final Object o) throws SonosApiClientException { validateNotNull(o, null); diff --git a/src/test/java/engineer/nightowl/sonos/api/resource/BaseResourceTest.java b/src/test/java/engineer/nightowl/sonos/api/resource/BaseResourceTest.java index 74fb578..9ba62cf 100644 --- a/src/test/java/engineer/nightowl/sonos/api/resource/BaseResourceTest.java +++ b/src/test/java/engineer/nightowl/sonos/api/resource/BaseResourceTest.java @@ -17,11 +17,13 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -213,6 +215,22 @@ void getStandardRequest() throws SonosApiClientException assertEquals("/control/api/some/path", req.uri().getPath()); } + @Test + void getStandardRequestAppliesConfiguredRequestTimeout() throws SonosApiClientException + { + when(configuration.getRequestTimeout()).thenReturn(Duration.ofSeconds(30)); + final HttpRequest req = baseResource.getStandardRequest("token123", "/some/path").GET().build(); + assertEquals(Duration.ofSeconds(30), req.timeout().orElse(null)); + } + + @Test + void getStandardRequestSkipsNonPositiveRequestTimeout() throws SonosApiClientException + { + when(configuration.getRequestTimeout()).thenReturn(Duration.ZERO); + final HttpRequest req = baseResource.getStandardRequest("token123", "/some/path").GET().build(); + assertTrue(req.timeout().isEmpty()); + } + @Test void testValidateNotNullNoFieldName() throws SonosApiClientException { From 9282919f512a5b7dd3530a26745f9a921acd284b Mon Sep 17 00:00:00 2001 From: James Milligan Date: Fri, 3 Jul 2026 12:54:40 -0400 Subject: [PATCH 2/5] Send no body for empty POST requests instead of [] --- .../engineer/nightowl/sonos/api/resource/BaseResource.java | 5 ++++- .../nightowl/sonos/api/resource/PlaybackResourceTest.java | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java b/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java index 77ce4d1..559dda2 100644 --- a/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java +++ b/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java @@ -212,7 +212,10 @@ T deleteFromApi(final Class returnType, final String token, final String */ T postToApi(final Class returnType, final String token, final String path) throws SonosApiClientException, SonosApiError { - return postToApi(returnType, token, path, new String[0]); + // Pass null (not an empty array) so isEmpty() sees "no content" and the request is sent with + // no body. isEmpty() does not treat a non-null array as empty, so a sentinel like new String[0] + // would be serialized to "[]" and sent as a bogus application/json body. + return postToApi(returnType, token, path, (Object) null); } /** diff --git a/src/test/java/engineer/nightowl/sonos/api/resource/PlaybackResourceTest.java b/src/test/java/engineer/nightowl/sonos/api/resource/PlaybackResourceTest.java index 593f895..aff4f75 100644 --- a/src/test/java/engineer/nightowl/sonos/api/resource/PlaybackResourceTest.java +++ b/src/test/java/engineer/nightowl/sonos/api/resource/PlaybackResourceTest.java @@ -10,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class PlaybackResourceTest extends MockedApiTestSetup { @@ -60,7 +61,11 @@ void testPlay() throws Exception resource.play("token123", "group1"); - assertEquals("/control/api/v1/groups/group1/playback/play", captureRequest().uri().getPath()); + final HttpRequest request = captureRequest(); + assertEquals("/control/api/v1/groups/group1/playback/play", request.uri().getPath()); + // A no-body POST must not send a bogus request body (e.g. "[]") + assertTrue(request.bodyPublisher().isPresent()); + assertEquals(0, request.bodyPublisher().get().contentLength()); } @Test From bcbf253a14e1c485a3e57d9839182e567e35e006 Mon Sep 17 00:00:00 2001 From: James Milligan Date: Fri, 3 Jul 2026 12:55:47 -0400 Subject: [PATCH 3/5] Only close the HttpClient when this client created it --- .../nightowl/sonos/api/SonosApiClient.java | 31 +++++++++++-- .../sonos/api/SonosApiClientTest.java | 44 +++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 src/test/java/engineer/nightowl/sonos/api/SonosApiClientTest.java diff --git a/src/main/java/engineer/nightowl/sonos/api/SonosApiClient.java b/src/main/java/engineer/nightowl/sonos/api/SonosApiClient.java index 2e3115d..c35ca16 100644 --- a/src/main/java/engineer/nightowl/sonos/api/SonosApiClient.java +++ b/src/main/java/engineer/nightowl/sonos/api/SonosApiClient.java @@ -47,6 +47,9 @@ public class SonosApiClient implements AutoCloseable // Can be overridden by implementing applications private SonosApiConfiguration configuration; private HttpClient httpClient; + // Whether this client created the HttpClient itself (and may therefore close it). A caller-supplied + // client is owned by the caller and must not be closed on our behalf. + private boolean httpClientOwned; private final String version; @@ -77,8 +80,17 @@ public SonosApiClient(final SonosApiConfiguration configuration, final HttpClien logger.info("Initialising sonos-api-java:{}", version); this.configuration = configuration; - this.httpClient = (httpClient == null ? generateHttpClient() : httpClient); - + if (httpClient == null) + { + this.httpClient = generateHttpClient(); + this.httpClientOwned = true; + } + else + { + this.httpClient = httpClient; + this.httpClientOwned = false; + } + // Setup resources audioClipResource = new AudioClipResource(this); @@ -122,11 +134,20 @@ private HttpClient generateHttpClient() } /** - * Close the HTTP client. + * Close the HTTP client, but only if this instance created it. A caller-supplied {@link HttpClient} + * is owned by the caller (it may be shared elsewhere), so it is left open; close it yourself via + * {@link #getHttpClient()} if required. */ public void closeHttpClient() { - httpClient.close(); + if (httpClientOwned) + { + httpClient.close(); + } + else + { + logger.debug("Not closing caller-supplied HttpClient"); + } } /** @@ -147,6 +168,8 @@ public HttpClient getHttpClient() public void setHttpClient(final HttpClient httpClient) { this.httpClient = httpClient; + // A client set after construction is caller-supplied and must not be closed on their behalf. + this.httpClientOwned = false; } private void loadProperties() diff --git a/src/test/java/engineer/nightowl/sonos/api/SonosApiClientTest.java b/src/test/java/engineer/nightowl/sonos/api/SonosApiClientTest.java new file mode 100644 index 0000000..2b349b8 --- /dev/null +++ b/src/test/java/engineer/nightowl/sonos/api/SonosApiClientTest.java @@ -0,0 +1,44 @@ +package engineer.nightowl.sonos.api; + +import org.junit.jupiter.api.Test; + +import java.net.http.HttpClient; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class SonosApiClientTest +{ + private static SonosApiConfiguration testConfiguration() + { + final SonosApiConfiguration configuration = new SonosApiConfiguration(); + configuration.setApiKey("testApiKey"); + configuration.setApiSecret("testApiSecret"); + configuration.setApplicationId("test"); + return configuration; + } + + @Test + void closeDoesNotCloseCallerSuppliedClient() + { + final HttpClient suppliedClient = mock(HttpClient.class); + final SonosApiClient client = new SonosApiClient(testConfiguration(), suppliedClient); + + client.close(); + + verify(suppliedClient, never()).close(); + } + + @Test + void closeDoesNotCloseClientSuppliedViaSetter() + { + final SonosApiClient client = new SonosApiClient(testConfiguration()); + final HttpClient suppliedClient = mock(HttpClient.class); + client.setHttpClient(suppliedClient); + + client.close(); + + verify(suppliedClient, never()).close(); + } +} From c122090291f12f9249ce8e222ea3c93aa85fe87c Mon Sep 17 00:00:00 2001 From: James Milligan Date: Fri, 3 Jul 2026 12:57:19 -0400 Subject: [PATCH 4/5] Log response bodies as text and redact token responses --- .../sonos/api/resource/BaseResource.java | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java b/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java index 559dda2..5ec1ee8 100644 --- a/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java +++ b/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java @@ -70,7 +70,20 @@ T callApi(final HttpRequest request, final Class type) throws SonosApiCli final byte[] bytes = response.body(); logger.debug("Raw response from API: {}", response); - logger.debug("Raw response content from API: {}", bytes); + if (logger.isDebugEnabled()) + { + // A token response body contains OAuth access/refresh tokens - never write those to logs, + // even at debug. Other bodies are decoded as text (logging the raw byte[] would just print + // its identity hash, which is useless). + if (isTokenResponse(request)) + { + logger.debug("Raw response content from API: "); + } + else + { + logger.debug("Raw response content from API: {}", new String(bytes, StandardCharsets.UTF_8)); + } + } // Get type from Sonos response - not always possible final SonosType sonosDeclaredClass = getTypeFromHeader(response); @@ -322,6 +335,19 @@ HttpRequest getDeleteRequest(final String token, final String path) throws Sonos return getStandardRequest(token, path).DELETE().build(); } + /** + * Whether a request targets the OAuth token endpoint, whose response body carries access/refresh + * tokens that must be kept out of logs. + * + * @param request the request being made + * @return true if this is a token request + */ + private static boolean isTokenResponse(final HttpRequest request) + { + final String path = request.uri().getPath(); + return path != null && path.contains("/oauth/access"); + } + /** * A timeout only applies if it is present and strictly positive - {@link HttpRequest.Builder#timeout} * and {@link java.net.http.HttpClient.Builder#connectTimeout} both reject zero/negative durations, so a From b5c333749f261491e8f34c968071c63adb952d44 Mon Sep 17 00:00:00 2001 From: James Milligan Date: Fri, 3 Jul 2026 13:33:36 -0400 Subject: [PATCH 5/5] Improve non-200 responses, encode API path segments Signed-off-by: James Milligan --- .../sonos/api/resource/BaseResource.java | 62 ++++++++++++++++--- .../sonos/api/resource/BaseResourceTest.java | 28 +++++++++ 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java b/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java index 5ec1ee8..73f0153 100644 --- a/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java +++ b/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java @@ -16,6 +16,7 @@ import java.io.IOException; import java.net.URI; +import java.net.URISyntaxException; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; @@ -89,6 +90,18 @@ T callApi(final HttpRequest request, final Class type) throws SonosApiCli final SonosType sonosDeclaredClass = getTypeFromHeader(response); final String sonosDeclaredClassName = sonosDeclaredClass == null ? null : sonosDeclaredClass.getClazz().getSimpleName(); + // A non-2xx response that Sonos did not describe with a structured error type would otherwise be + // handed to the deserializer for the requested type, producing a confusing "converting response" + // parse error. Surface the HTTP status (and a short body snippet) instead. Responses that DO carry + // a declared error type still flow through to the SonosApiError handling below, regardless of status. + final int status = response.statusCode(); + final boolean sonosDeclaredError = sonosDeclaredClass != null && SonosType.getErrorTypes().contains(sonosDeclaredClass); + if ((status < 200 || status >= 300) && !sonosDeclaredError) + { + final String detail = isTokenResponse(request) ? "" : bodySnippet(bytes); + throw new SonosApiClientException(String.format("Sonos API returned HTTP %d: %s", status, detail)); + } + // If Sonos didn't provide a type, or if one was provided and it matches what we wanted returned, proceed if (sonosDeclaredClassName == null || sonosDeclaredClassName.equals(type.getSimpleName())) { @@ -290,14 +303,7 @@ T postToApi(final Class returnType, final String token, final String p HttpRequest.Builder getStandardRequest(final String token, final String path) throws SonosApiClientException { final SonosApiConfiguration configuration = apiClient.getConfiguration(); - final URI uri; - try - { - uri = URI.create("https://" + configuration.getControlBaseUrl() + path); - } catch (final IllegalArgumentException e) - { - throw new SonosApiClientException("Invalid URI built", e); - } + final URI uri = buildControlUri(configuration.getControlBaseUrl(), path); final HttpRequest.Builder builder = HttpRequest.newBuilder(uri) .setHeader("Authorization", String.format("Bearer %s", token)) @@ -309,6 +315,32 @@ HttpRequest.Builder getStandardRequest(final String token, final String path) th return builder; } + /** + * Build the control API URI for a path, percent-encoding the path segments (which may contain + * interpolated resource IDs). {@code controlBaseUrl} bundles the host with a base path, e.g. + * {@code api.ws.sonos.com/control/api}, so it is split on the first {@code /} to let the multi-argument + * {@link URI} constructor encode the assembled path - unlike {@link URI#create(String)}, which requires + * an already-valid URI string and would reject (rather than encode) a stray space or reserved character. + * + * @param controlBaseUrl the configured control base URL (host + optional base path) + * @param path the resource path, already interpolated with any IDs + * @return the assembled, encoded URI + * @throws SonosApiClientException if the URI could not be built + */ + private static URI buildControlUri(final String controlBaseUrl, final String path) throws SonosApiClientException + { + final int firstSlash = controlBaseUrl.indexOf('/'); + final String host = firstSlash < 0 ? controlBaseUrl : controlBaseUrl.substring(0, firstSlash); + final String basePath = firstSlash < 0 ? "" : controlBaseUrl.substring(firstSlash); + try + { + return new URI("https", host, basePath + path, null); + } catch (final URISyntaxException e) + { + throw new SonosApiClientException("Invalid URI built", e); + } + } + /** * Helper method to generate a basic GET request * @@ -348,6 +380,20 @@ private static boolean isTokenResponse(final HttpRequest request) return path != null && path.contains("/oauth/access"); } + /** + * Decode a response body as text for inclusion in an error message, truncated so an unexpectedly large + * body (e.g. an HTML error page from an intermediary) cannot bloat the exception. + * + * @param bytes the raw response body + * @return a short, human-readable snippet of the body + */ + private static String bodySnippet(final byte[] bytes) + { + final String body = new String(bytes, StandardCharsets.UTF_8); + final int maxLength = 512; + return body.length() <= maxLength ? body : body.substring(0, maxLength) + "...(truncated)"; + } + /** * A timeout only applies if it is present and strictly positive - {@link HttpRequest.Builder#timeout} * and {@link java.net.http.HttpClient.Builder#connectTimeout} both reject zero/negative durations, so a diff --git a/src/test/java/engineer/nightowl/sonos/api/resource/BaseResourceTest.java b/src/test/java/engineer/nightowl/sonos/api/resource/BaseResourceTest.java index 9ba62cf..2eb5f40 100644 --- a/src/test/java/engineer/nightowl/sonos/api/resource/BaseResourceTest.java +++ b/src/test/java/engineer/nightowl/sonos/api/resource/BaseResourceTest.java @@ -177,6 +177,22 @@ void testApiErrorHandledCorrectly() throws Exception } } + @Test + void testNonSuccessStatusWithoutErrorTypeThrowsWithStatus() throws Exception + { + final byte[] bytes = "Service Unavailable".getBytes(StandardCharsets.UTF_8); + + final HttpResponse mockedResponse = mock(HttpResponse.class); + when(mockedResponse.body()).thenReturn(bytes); + when(mockedResponse.statusCode()).thenReturn(503); + when(mockedResponse.headers()).thenReturn(HttpHeaders.of(Map.of(), (a, b) -> true)); + when(mockedClient.send(any(HttpRequest.class), ArgumentMatchers.>any())).thenReturn(mockedResponse); + + final SonosApiClientException e = assertThrows(SonosApiClientException.class, + () -> baseResource.getFromApi(SonosHomeTheaterOptions.class, "token123", "some/test")); + assertEquals("Sonos API returned HTTP 503: Service Unavailable", e.getMessage()); + } + @Test void testApiMismatchHandledCorrectly() throws Exception { @@ -215,6 +231,18 @@ void getStandardRequest() throws SonosApiClientException assertEquals("/control/api/some/path", req.uri().getPath()); } + @Test + void getStandardRequestEncodesPathSegments() throws SonosApiClientException + { + // An id containing a character that is illegal in a raw URI (here a space) must be percent-encoded + // rather than rejected. getPath() returns the decoded path; getRawPath() shows the encoding. + final HttpRequest req = baseResource.getStandardRequest("token123", "/v1/players/a b/playerVolume").GET().build(); + + assertEquals("api.example.com", req.uri().getHost()); + assertEquals("/control/api/v1/players/a b/playerVolume", req.uri().getPath()); + assertEquals("/control/api/v1/players/a%20b/playerVolume", req.uri().getRawPath()); + } + @Test void getStandardRequestAppliesConfiguredRequestTimeout() throws SonosApiClientException {