Skip to content

Commit 2829125

Browse files
Improve non-200 responses, encode API path segments
Signed-off-by: James Milligan <hello@nightowl.engineer>
1 parent 69e0fc4 commit 2829125

2 files changed

Lines changed: 82 additions & 8 deletions

File tree

src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import java.io.IOException;
1818
import java.net.URI;
19+
import java.net.URISyntaxException;
1920
import java.net.http.HttpRequest;
2021
import java.net.http.HttpResponse;
2122
import java.nio.charset.StandardCharsets;
@@ -89,6 +90,18 @@ <T> T callApi(final HttpRequest request, final Class<T> type) throws SonosApiCli
8990
final SonosType sonosDeclaredClass = getTypeFromHeader(response);
9091
final String sonosDeclaredClassName = sonosDeclaredClass == null ? null : sonosDeclaredClass.getClazz().getSimpleName();
9192

93+
// A non-2xx response that Sonos did not describe with a structured error type would otherwise be
94+
// handed to the deserializer for the requested type, producing a confusing "converting response"
95+
// parse error. Surface the HTTP status (and a short body snippet) instead. Responses that DO carry
96+
// a declared error type still flow through to the SonosApiError handling below, regardless of status.
97+
final int status = response.statusCode();
98+
final boolean sonosDeclaredError = sonosDeclaredClass != null && SonosType.getErrorTypes().contains(sonosDeclaredClass);
99+
if ((status < 200 || status >= 300) && !sonosDeclaredError)
100+
{
101+
final String detail = isTokenResponse(request) ? "<redacted - token response>" : bodySnippet(bytes);
102+
throw new SonosApiClientException(String.format("Sonos API returned HTTP %d: %s", status, detail));
103+
}
104+
92105
// If Sonos didn't provide a type, or if one was provided and it matches what we wanted returned, proceed
93106
if (sonosDeclaredClassName == null || sonosDeclaredClassName.equals(type.getSimpleName()))
94107
{
@@ -290,14 +303,7 @@ <T, U> T postToApi(final Class<T> returnType, final String token, final String p
290303
HttpRequest.Builder getStandardRequest(final String token, final String path) throws SonosApiClientException
291304
{
292305
final SonosApiConfiguration configuration = apiClient.getConfiguration();
293-
final URI uri;
294-
try
295-
{
296-
uri = URI.create("https://" + configuration.getControlBaseUrl() + path);
297-
} catch (final IllegalArgumentException e)
298-
{
299-
throw new SonosApiClientException("Invalid URI built", e);
300-
}
306+
final URI uri = buildControlUri(configuration.getControlBaseUrl(), path);
301307

302308
final HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
303309
.setHeader("Authorization", String.format("Bearer %s", token))
@@ -309,6 +315,32 @@ HttpRequest.Builder getStandardRequest(final String token, final String path) th
309315
return builder;
310316
}
311317

318+
/**
319+
* Build the control API URI for a path, percent-encoding the path segments (which may contain
320+
* interpolated resource IDs). {@code controlBaseUrl} bundles the host with a base path, e.g.
321+
* {@code api.ws.sonos.com/control/api}, so it is split on the first {@code /} to let the multi-argument
322+
* {@link URI} constructor encode the assembled path - unlike {@link URI#create(String)}, which requires
323+
* an already-valid URI string and would reject (rather than encode) a stray space or reserved character.
324+
*
325+
* @param controlBaseUrl the configured control base URL (host + optional base path)
326+
* @param path the resource path, already interpolated with any IDs
327+
* @return the assembled, encoded URI
328+
* @throws SonosApiClientException if the URI could not be built
329+
*/
330+
private static URI buildControlUri(final String controlBaseUrl, final String path) throws SonosApiClientException
331+
{
332+
final int firstSlash = controlBaseUrl.indexOf('/');
333+
final String host = firstSlash < 0 ? controlBaseUrl : controlBaseUrl.substring(0, firstSlash);
334+
final String basePath = firstSlash < 0 ? "" : controlBaseUrl.substring(firstSlash);
335+
try
336+
{
337+
return new URI("https", host, basePath + path, null);
338+
} catch (final URISyntaxException e)
339+
{
340+
throw new SonosApiClientException("Invalid URI built", e);
341+
}
342+
}
343+
312344
/**
313345
* Helper method to generate a basic GET request
314346
*
@@ -348,6 +380,20 @@ private static boolean isTokenResponse(final HttpRequest request)
348380
return path != null && path.contains("/oauth/access");
349381
}
350382

383+
/**
384+
* Decode a response body as text for inclusion in an error message, truncated so an unexpectedly large
385+
* body (e.g. an HTML error page from an intermediary) cannot bloat the exception.
386+
*
387+
* @param bytes the raw response body
388+
* @return a short, human-readable snippet of the body
389+
*/
390+
private static String bodySnippet(final byte[] bytes)
391+
{
392+
final String body = new String(bytes, StandardCharsets.UTF_8);
393+
final int maxLength = 512;
394+
return body.length() <= maxLength ? body : body.substring(0, maxLength) + "...(truncated)";
395+
}
396+
351397
/**
352398
* A timeout only applies if it is present and strictly positive - {@link HttpRequest.Builder#timeout}
353399
* and {@link java.net.http.HttpClient.Builder#connectTimeout} both reject zero/negative durations, so a

src/test/java/engineer/nightowl/sonos/api/resource/BaseResourceTest.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,22 @@ void testApiErrorHandledCorrectly() throws Exception
177177
}
178178
}
179179

180+
@Test
181+
void testNonSuccessStatusWithoutErrorTypeThrowsWithStatus() throws Exception
182+
{
183+
final byte[] bytes = "Service Unavailable".getBytes(StandardCharsets.UTF_8);
184+
185+
final HttpResponse<byte[]> mockedResponse = mock(HttpResponse.class);
186+
when(mockedResponse.body()).thenReturn(bytes);
187+
when(mockedResponse.statusCode()).thenReturn(503);
188+
when(mockedResponse.headers()).thenReturn(HttpHeaders.of(Map.of(), (a, b) -> true));
189+
when(mockedClient.send(any(HttpRequest.class), ArgumentMatchers.<HttpResponse.BodyHandler<byte[]>>any())).thenReturn(mockedResponse);
190+
191+
final SonosApiClientException e = assertThrows(SonosApiClientException.class,
192+
() -> baseResource.getFromApi(SonosHomeTheaterOptions.class, "token123", "some/test"));
193+
assertEquals("Sonos API returned HTTP 503: Service Unavailable", e.getMessage());
194+
}
195+
180196
@Test
181197
void testApiMismatchHandledCorrectly() throws Exception
182198
{
@@ -215,6 +231,18 @@ void getStandardRequest() throws SonosApiClientException
215231
assertEquals("/control/api/some/path", req.uri().getPath());
216232
}
217233

234+
@Test
235+
void getStandardRequestEncodesPathSegments() throws SonosApiClientException
236+
{
237+
// An id containing a character that is illegal in a raw URI (here a space) must be percent-encoded
238+
// rather than rejected. getPath() returns the decoded path; getRawPath() shows the encoding.
239+
final HttpRequest req = baseResource.getStandardRequest("token123", "/v1/players/a b/playerVolume").GET().build();
240+
241+
assertEquals("api.example.com", req.uri().getHost());
242+
assertEquals("/control/api/v1/players/a b/playerVolume", req.uri().getPath());
243+
assertEquals("/control/api/v1/players/a%20b/playerVolume", req.uri().getRawPath());
244+
}
245+
218246
@Test
219247
void getStandardRequestAppliesConfiguredRequestTimeout() throws SonosApiClientException
220248
{

0 commit comments

Comments
 (0)