From 64bbcde5a2606e62d95ae7d295d7e615493ee677 Mon Sep 17 00:00:00 2001 From: James Milligan Date: Mon, 18 Jul 2022 17:07:59 -0400 Subject: [PATCH] WIP for transitioning to Java HTTP lib --- pom.xml | 5 - .../nightowl/sonos/api/SonosApiClient.java | 52 +---- .../sonos/api/SonosApiConfiguration.java | 156 +++---------- .../sonos/api/domain/SonosSessionError.java | 1 + .../sonos/api/resource/AuthorizeResource.java | 123 ++++------ .../sonos/api/resource/BaseResource.java | 213 ++++++++++-------- .../sonos/api/util/SignatureHeader.java | 35 +++ .../sonos/api/util/SonosCallbackHelper.java | 65 +++--- .../api/resource/AuthorizeResourceTest.java | 34 ++- .../sonos/api/resource/BaseResourceTest.java | 71 +++--- .../nightowl/sonos/api/util/HeaderFilter.java | 12 + .../api/util/SonosCallbackHelperTest.java | 43 ++-- 12 files changed, 360 insertions(+), 450 deletions(-) create mode 100644 src/main/java/engineer/nightowl/sonos/api/util/SignatureHeader.java create mode 100644 src/test/java/engineer/nightowl/sonos/api/util/HeaderFilter.java diff --git a/pom.xml b/pom.xml index e5d06ff..2ebfd9f 100644 --- a/pom.xml +++ b/pom.xml @@ -84,11 +84,6 @@ commons-lang3 3.13.0 - - org.apache.httpcomponents - httpclient - 4.5.14 - com.fasterxml.jackson.core jackson-databind diff --git a/src/main/java/engineer/nightowl/sonos/api/SonosApiClient.java b/src/main/java/engineer/nightowl/sonos/api/SonosApiClient.java index 3bc4c98..18c9608 100644 --- a/src/main/java/engineer/nightowl/sonos/api/SonosApiClient.java +++ b/src/main/java/engineer/nightowl/sonos/api/SonosApiClient.java @@ -15,16 +15,14 @@ import engineer.nightowl.sonos.api.resource.PlaylistResource; import engineer.nightowl.sonos.api.resource.SettingsResource; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.util.VersionInfo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; +import java.net.http.HttpClient; import java.util.Properties; -public class SonosApiClient implements AutoCloseable +public class SonosApiClient { // Resources private final AudioClipResource audioClipResource; @@ -48,7 +46,7 @@ public class SonosApiClient implements AutoCloseable // Can be overridden by implementing applications private SonosApiConfiguration configuration; - private CloseableHttpClient httpClient; + private HttpClient httpClient; private final String version; @@ -58,7 +56,7 @@ public class SonosApiClient implements AutoCloseable * @param configuration a {@link engineer.nightowl.sonos.api.SonosApiConfiguration} containing integration * information such as API keys * - * @see SonosApiClient#SonosApiClient(SonosApiConfiguration, CloseableHttpClient) + * @see SonosApiClient#SonosApiClient(SonosApiConfiguration, HttpClient) */ public SonosApiClient(final SonosApiConfiguration configuration) { @@ -70,9 +68,9 @@ public SonosApiClient(final SonosApiConfiguration configuration) * * @param configuration a {@link engineer.nightowl.sonos.api.SonosApiConfiguration} containing integration * information such as API keys - * @param httpClient a custom {@link CloseableHttpClient} - if null, a default client is initialised + * @param httpClient a custom {@link HttpClient} - if null, a default client is initialised */ - public SonosApiClient(final SonosApiConfiguration configuration, final CloseableHttpClient httpClient) + public SonosApiClient(final SonosApiConfiguration configuration, final HttpClient httpClient) { loadProperties(); version = properties.getProperty("sonosapijava.version"); @@ -101,11 +99,8 @@ public SonosApiClient(final SonosApiConfiguration configuration, final Closeable public String getUserAgent() { - final String ahcUa = VersionInfo.getUserAgent("Apache-HttpClient", - "org.apache.http.client", HttpClientBuilder.class); - - return String.format("sonos-api-java/%s (applicationId/%s) (httpClient/(%s))", - version, configuration.getApplicationId(), ahcUa); + return String.format("sonos-api-java/%s (applicationId/%s))", + version, configuration.getApplicationId()); } /** @@ -113,24 +108,10 @@ public String getUserAgent() * * @return a default HTTP client. */ - private CloseableHttpClient generateHttpClient() + private HttpClient generateHttpClient() { logger.debug("Using default HttpClient"); - return HttpClientBuilder.create().setUserAgent(getUserAgent()).build(); - } - - /** - * Close the HTTP client. - */ - public void closeHttpClient() - { - try - { - httpClient.close(); - } catch (final IOException ioe) - { - logger.warn("Unable to close HttpClient", ioe); - } + return HttpClient.newHttpClient(); } /** @@ -138,7 +119,7 @@ public void closeHttpClient() * * @return the configured HTTP client */ - public CloseableHttpClient getHttpClient() + public HttpClient getHttpClient() { return httpClient; } @@ -148,7 +129,7 @@ public CloseableHttpClient getHttpClient() * * @param httpClient custom client to set */ - public void setHttpClient(final CloseableHttpClient httpClient) + public void setHttpClient(final HttpClient httpClient) { this.httpClient = httpClient; } @@ -323,13 +304,4 @@ public SettingsResource settings() return settingsResource; } - - /** - * Closes the HttpClient - */ - @Override - public void close() - { - closeHttpClient(); - } } diff --git a/src/main/java/engineer/nightowl/sonos/api/SonosApiConfiguration.java b/src/main/java/engineer/nightowl/sonos/api/SonosApiConfiguration.java index f3f4e20..d8fabbf 100644 --- a/src/main/java/engineer/nightowl/sonos/api/SonosApiConfiguration.java +++ b/src/main/java/engineer/nightowl/sonos/api/SonosApiConfiguration.java @@ -1,13 +1,11 @@ package engineer.nightowl.sonos.api; -import org.apache.commons.codec.binary.Base64; -import org.apache.commons.lang3.builder.EqualsBuilder; -import org.apache.commons.lang3.builder.HashCodeBuilder; -import org.apache.http.Header; -import org.apache.http.message.BasicHeader; +import java.util.Base64; +import java.util.Objects; /** - * Configuration class to be built up and passed into a {@link engineer.nightowl.sonos.api.SonosApiClient} + * Configuration class to be built up and passed into a + * {@link engineer.nightowl.sonos.api.SonosApiClient} *

* Loads defaults on construction. */ @@ -21,172 +19,92 @@ public class SonosApiConfiguration private String controlBaseUrl; private Boolean clientSideValidationEnabled; - /** - *

Constructor for SonosApiConfiguration.

- */ - public SonosApiConfiguration() - { + public SonosApiConfiguration() { loadDefaults(); } - /** - *

Getter for the field applicationId.

- * - * @return a {@link java.lang.String} object. - */ - public String getApplicationId() - { + public String getApplicationId() { return applicationId; } - /** - *

Setter for the field applicationId.

- * - * @param applicationId a {@link java.lang.String} object. - */ - public void setApplicationId(final String applicationId) - { + public void setApplicationId(final String applicationId) { this.applicationId = applicationId; } - /** - *

Getter for the field apiKey.

- * - * @return a {@link java.lang.String} object. - */ - public String getApiKey() - { + public String getApiKey() { return apiKey; } - /** - *

Setter for the field apiKey.

- * - * @param apiKey a {@link java.lang.String} object. - */ - public void setApiKey(final String apiKey) - { + public void setApiKey(final String apiKey) { this.apiKey = apiKey; } - public String getApiSecret() - { + public String getApiSecret() { return apiSecret; } - /** - *

Setter for the field apiSecret.

- * - * @param apiSecret a {@link java.lang.String} object. - */ - public void setApiSecret(final String apiSecret) - { + public void setApiSecret(final String apiSecret) { this.apiSecret = apiSecret; } - /** - *

Getter for the field authBaseUrl.

- * - * @return a {@link java.lang.String} object. - */ - public String getAuthBaseUrl() - { + public String getAuthBaseUrl() { return authBaseUrl; } - public void setAuthBaseUrl(final String authBaseUrl) - { + public void setAuthBaseUrl(final String authBaseUrl) { this.authBaseUrl = authBaseUrl; } - /** - *

Getter for the field controlBaseUrl.

- * - * @return a {@link java.lang.String} object. - */ - public String getControlBaseUrl() - { + public String getControlBaseUrl() { return controlBaseUrl; } - public void setControlBaseUrl(final String controlBaseUrl) - { + public void setControlBaseUrl(final String controlBaseUrl) { this.controlBaseUrl = controlBaseUrl; } - public Boolean isClientSideValidationEnabled() - { + public Boolean isClientSideValidationEnabled() { return clientSideValidationEnabled; } - public void setClientSideValidationEnabled(Boolean clientSideValidationEnabled) - { + public void setClientSideValidationEnabled(Boolean clientSideValidationEnabled) { this.clientSideValidationEnabled = clientSideValidationEnabled; } - - public void loadDefaults() - { + public void loadDefaults() { setAuthBaseUrl("api.sonos.com"); setControlBaseUrl("api.ws.sonos.com/control/api"); setClientSideValidationEnabled(Boolean.TRUE); } - /** - *

getAuthorizationHeader.

- * - * @return a {@link org.apache.http.Header} object. - */ - public Header getAuthorizationHeader() - { + public String getAuthorizationHeaderValue() { final byte[] authBytes = String.join(":", getApiKey(), getApiSecret()).getBytes(); - final String authBase64 = Base64.encodeBase64String(authBytes); - final String headerValue = String.join(" ", "Basic", authBase64); - return new BasicHeader("Authorization", headerValue); + final String authBase64 = Base64.getEncoder().encodeToString(authBytes); + return String.join(" ", "Basic", authBase64); } @Override - public String toString() - { - return "SonosApiConfiguration{" + - "applicationId='" + applicationId + '\'' + - ", apiKey='" + apiKey + '\'' + - ", apiSecret='" + apiSecret + '\'' + - ", authBaseUrl='" + authBaseUrl + '\'' + - ", controlBaseUrl='" + controlBaseUrl + '\'' + - ", clientSideValidationEnabled=" + clientSideValidationEnabled + - '}'; + public String toString() { + return "SonosApiConfiguration [apiKey=" + apiKey + ", apiSecret=" + apiSecret + ", applicationId=" + + applicationId + ", authBaseUrl=" + authBaseUrl + ", clientSideValidationEnabled=" + + clientSideValidationEnabled + ", controlBaseUrl=" + controlBaseUrl + "]"; } @Override - public boolean equals(Object o) - { - if (this == o) return true; - - if (o == null || getClass() != o.getClass()) return false; - - SonosApiConfiguration that = (SonosApiConfiguration) o; - - return new EqualsBuilder() - .append(applicationId, that.applicationId) - .append(apiKey, that.apiKey) - .append(apiSecret, that.apiSecret) - .append(authBaseUrl, that.authBaseUrl) - .append(controlBaseUrl, that.controlBaseUrl) - .append(clientSideValidationEnabled, that.clientSideValidationEnabled) - .isEquals(); + public boolean equals(Object obj) { + if (this == obj) + return true; + if (!(obj instanceof SonosApiConfiguration)) + return false; + SonosApiConfiguration other = (SonosApiConfiguration) obj; + return Objects.equals(apiKey, other.apiKey) && Objects.equals(apiSecret, other.apiSecret) + && Objects.equals(applicationId, other.applicationId) && Objects.equals(authBaseUrl, other.authBaseUrl) + && Objects.equals(clientSideValidationEnabled, other.clientSideValidationEnabled) + && Objects.equals(controlBaseUrl, other.controlBaseUrl); } @Override - public int hashCode() - { - return new HashCodeBuilder(17, 37) - .append(applicationId) - .append(apiKey) - .append(apiSecret) - .append(authBaseUrl) - .append(controlBaseUrl) - .append(clientSideValidationEnabled) - .toHashCode(); + public int hashCode() { + return Objects.hash(apiKey, apiSecret, applicationId, authBaseUrl, clientSideValidationEnabled, controlBaseUrl); } } diff --git a/src/main/java/engineer/nightowl/sonos/api/domain/SonosSessionError.java b/src/main/java/engineer/nightowl/sonos/api/domain/SonosSessionError.java index ff5e266..1fa0ece 100644 --- a/src/main/java/engineer/nightowl/sonos/api/domain/SonosSessionError.java +++ b/src/main/java/engineer/nightowl/sonos/api/domain/SonosSessionError.java @@ -8,6 +8,7 @@ */ public class SonosSessionError extends SonosApiError { + private static final long serialVersionUID = 9183301150166556145L; private String sessionId; private SonosSessionErrorCode errorCode; } 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 dfe04e6..9c6c69d 100644 --- a/src/main/java/engineer/nightowl/sonos/api/resource/AuthorizeResource.java +++ b/src/main/java/engineer/nightowl/sonos/api/resource/AuthorizeResource.java @@ -5,18 +5,16 @@ import engineer.nightowl.sonos.api.domain.SonosToken; import engineer.nightowl.sonos.api.exception.SonosApiClientException; import engineer.nightowl.sonos.api.exception.SonosApiError; -import org.apache.http.client.entity.UrlEncodedFormEntity; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.client.utils.URIBuilder; -import org.apache.http.message.BasicNameValuePair; -import java.io.UnsupportedEncodingException; import java.math.BigInteger; import java.net.URI; import java.net.URISyntaxException; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; import java.security.SecureRandom; -import java.util.ArrayList; -import java.util.List; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; /** * The Authorization flow is dependent on sending your user back to a pre-registered, and user-accessible @@ -52,22 +50,21 @@ public AuthorizeResource(final SonosApiClient apiClient) public URI getAuthorizeCodeUri(final String redirectUri, final String state) throws URISyntaxException { final SonosApiConfiguration configuration = apiClient.getConfiguration(); - final URIBuilder uri = new URIBuilder(); - uri.setScheme(HTTPS); - uri.setHost(configuration.getAuthBaseUrl()); - uri.setPath("/login/v3/oauth"); - uri.setParameter("client_id", configuration.getApiKey()); - uri.setParameter("redirect_uri", redirectUri); + final Map params = new HashMap<>(5); + params.put("client_id", configuration.getApiKey()); + params.put("redirect_uri", redirectUri); // Only currently supported values by Sonos - uri.setParameter("response_type", "code"); - uri.setParameter("scope", "playback-control-all"); + params.put("response_type", "code"); + params.put("scope", "playback-control-all"); // State is optional, only include it if set. if (state != null) { - uri.setParameter("state", state); + params.put("state", state); } - return uri.build(); + final String query = params.keySet().stream().map(key -> (key + "=" + params.get(key))).collect(Collectors.joining("&")); + + return new URI(HTTPS, configuration.getAuthBaseUrl(), "/login/v3/oauth", query, null); } /** @@ -97,39 +94,25 @@ public SonosToken createToken(final String redirectUri, final String authorizeCo SonosApiError { final SonosApiConfiguration configuration = apiClient.getConfiguration(); - final URIBuilder uri = new URIBuilder(); - uri.setScheme(HTTPS); - uri.setHost(configuration.getAuthBaseUrl()); - uri.setPath("/login/v3/oauth/access"); - - final BasicNameValuePair redirectUriParameter = new BasicNameValuePair("redirect_uri", redirectUri); - final BasicNameValuePair code = new BasicNameValuePair("code", authorizeCode); - final BasicNameValuePair grantType = new BasicNameValuePair("grant_type", "authorization_code"); - final HttpPost request = new HttpPost(); - - final List postParameters = new ArrayList<>(); - postParameters.add(redirectUriParameter); - postParameters.add(code); - postParameters.add(grantType); - - try - { - request.setEntity(new UrlEncodedFormEntity(postParameters)); - } catch (final UnsupportedEncodingException e) - { - throw new SonosApiClientException("Unable to generate auth request content", e); + final HttpRequest.Builder request = HttpRequest.newBuilder(); + URI uri; + try { + uri = new URI(HTTPS, configuration.getAuthBaseUrl(), "/login/v3/oauth/access", null, null); + } catch (URISyntaxException e) { + throw new SonosApiClientException("Invalid URI constructed", e); } - request.setHeader(configuration.getAuthorizationHeader()); - try - { - request.setURI(uri.build()); - } catch (final URISyntaxException e) - { - throw new SonosApiClientException("Invalid URI built", e); - } + request.setHeader("Authorization", configuration.getAuthorizationHeaderValue()); + request.uri(uri); + + final Map params = new HashMap<>(3); + params.put("redirect_uri", redirectUri); + params.put("code", authorizeCode); + params.put("grant_type", "authorization_code"); + + request.POST(BodyPublishers.ofString(params.keySet().stream().map(key -> (key + "=" + params.get(key))).collect(Collectors.joining("&")))); - return callApi(request, SonosToken.class); + return callApi(request.build(), SonosToken.class); } /** @@ -144,42 +127,24 @@ public SonosToken createToken(final String redirectUri, final String authorizeCo public SonosToken refreshToken(final String refreshToken) throws SonosApiClientException, SonosApiError { final SonosApiConfiguration configuration = apiClient.getConfiguration(); - - // Setup URI - final URIBuilder uri = new URIBuilder(); - uri.setScheme(HTTPS); - uri.setHost(configuration.getAuthBaseUrl()); - uri.setPath("/login/v3/oauth/access"); - - // Setup POST contents - final HttpPost request = new HttpPost(); - final BasicNameValuePair refreshTokenParameter = new BasicNameValuePair("refresh_token", refreshToken); - final BasicNameValuePair grantType = new BasicNameValuePair("grant_type", "refresh_token"); - final List postParameters = new ArrayList<>(); - postParameters.add(refreshTokenParameter); - postParameters.add(grantType); - - try - { - request.setEntity(new UrlEncodedFormEntity(postParameters)); - } catch (final UnsupportedEncodingException e) - { - throw new SonosApiClientException("Unable to generate auth request content", e); + final HttpRequest.Builder request = HttpRequest.newBuilder(); + URI uri; + try { + uri = new URI(HTTPS, configuration.getAuthBaseUrl(), "/login/v3/oauth/access", null, null); + } catch (URISyntaxException e) { + throw new SonosApiClientException("Invalid URI constructed", e); } - // Set authorization header - request.setHeader(configuration.getAuthorizationHeader()); + request.setHeader("Authorization", configuration.getAuthorizationHeaderValue()); + request.uri(uri); - // Execute request - try - { - request.setURI(uri.build()); - } catch (final URISyntaxException e) - { - throw new SonosApiClientException("Invalid URI built", e); - } + final Map params = new HashMap<>(3); + params.put("refresh_token", refreshToken); + params.put("grant_type", "refresh_token"); + + request.POST(BodyPublishers.ofString(params.keySet().stream().map(key -> (key + "=" + params.get(key))).collect(Collectors.joining("&")))); - return callApi(request, SonosToken.class); + return callApi(request.build(), SonosToken.class); } /** 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 0f0cc94..80a46fb 100644 --- a/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java +++ b/src/main/java/engineer/nightowl/sonos/api/resource/BaseResource.java @@ -11,88 +11,105 @@ import engineer.nightowl.sonos.api.exception.SonosApiError; import engineer.nightowl.sonos.api.specs.Validatable; import engineer.nightowl.sonos.api.util.SonosUtilityHelper; -import org.apache.http.Header; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpDelete; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.client.methods.HttpRequestBase; -import org.apache.http.client.methods.HttpUriRequest; -import org.apache.http.client.utils.URIBuilder; -import org.apache.http.entity.ContentType; -import org.apache.http.entity.StringEntity; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.InputStream; +import java.net.URI; import java.net.URISyntaxException; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; /** * Generic base class used for integrating with the Sonos API. */ -class BaseResource -{ +class BaseResource { // Default ObjectMapper. private static final ObjectMapper OM = new ObjectMapper() // Allow unknown properties - these may be added by Sonos, but Jackson will // error if not specified in the relevant POJO .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .setSerializationInclusion(JsonInclude.Include.NON_NULL); + + private static final String ASYNC_ERROR_MSG = "Failure during async method execution"; /** * Sonos can provide a header describing the response type * * @see engineer.nightowl.sonos.api.enums.SonosType */ static final String SONOS_TYPE_HEADER = "X-Sonos-Type"; - private final Logger logger = LoggerFactory.getLogger(getClass()); + protected final Logger logger = LoggerFactory.getLogger(getClass()); SonosApiClient apiClient; - BaseResource(final SonosApiClient apiClient) - { + BaseResource(final SonosApiClient apiClient) { this.apiClient = apiClient; } /** - * Main API call method. Takes in a {@link HttpUriRequest} comprising of a - * URI and method + * Main API call method. Takes in a {@link HttpUriRequest} comprising of a URI + * and method * * @param request with the relevant URI and method (with associated data as * appropriate) * @param type what the response should interpreted as * @return the type specified in 'type' * @throws SonosApiClientException if unable to build or execute the request - * @throws SonosApiError if the Sonos API returns an error to an otherwise successful request + * @throws SonosApiError if the Sonos API returns an error to an + * otherwise successful request + * @throws InterruptedException */ - T callApi(final HttpUriRequest request, final Class type) throws SonosApiClientException, SonosApiError + CompletableFuture callApiAsync(final HttpRequest request, final Class type) + throws SonosApiClientException, SonosApiError { - logger.debug("Sending request to {}", request.getURI()); - final CloseableHttpResponse response; - try - { - response = apiClient.getHttpClient().execute(request); - } catch (final IOException e) - { - throw new SonosApiClientException("Error interrogating Sonos API", e); + logger.debug("Sending request to {}", request.uri()); + return apiClient.getHttpClient().sendAsync(request, BodyHandlers.ofInputStream()).thenApply(resp -> handleApiResponseAsync(resp, type)); + } + + T callApi(final HttpRequest request, final Class type) throws SonosApiClientException, SonosApiError { + try { + return callApiAsync(request, type).get(); + } catch (InterruptedException | ExecutionException e) { + Thread.currentThread().interrupt(); + throw new SonosApiClientException(ASYNC_ERROR_MSG, e); } - if (401 == response.getStatusLine().getStatusCode()) + } + + T handleApiResponseAsync(final HttpResponse response, final Class type) + { + if (401 == response.statusCode()) { - throw new SonosApiClientException("Invalid token"); + throw new CompletionException(new SonosApiClientException("Invalid token")); } + final byte[] bytes; - try (final InputStream stream = response.getEntity().getContent()) + try (final InputStream stream = response.body()) { bytes = stream.readAllBytes(); } catch (final IOException ioe) { - throw new SonosApiClientException("Unable to convert response body", ioe); + throw new CompletionException(new SonosApiClientException("Unable to convert response body", ioe)); } logger.debug("Raw response from API: {}", response); logger.debug("Raw response content from API: {}", bytes); // Get type from Sonos response - not always possible - final SonosType sonosDeclaredClass = getTypeFromHeader(response); + SonosType sonosDeclaredClass; + try { + sonosDeclaredClass = getTypeFromHeader(response.headers()); + } catch (SonosApiClientException sace) { + throw new CompletionException(sace); + } + final String sonosDeclaredClassName = sonosDeclaredClass == null ? null : sonosDeclaredClass.getClazz().getSimpleName(); // If Sonos didn't provide a type, or if one was provided and it matches what we wanted returned, proceed @@ -104,7 +121,7 @@ T callApi(final HttpUriRequest request, final Class type) throws SonosApi } catch (final IOException | ClassNotFoundException e) { final String msg = String.format("Unexpected error converting response to %s (Sonos declared %s)", type.getSimpleName(), sonosDeclaredClassName); - throw new SonosApiClientException(msg, e); + throw new CompletionException(new SonosApiClientException(msg, e)); } } // Otherwise it's not what we expected - likely an error object, in which case throw an exception with the mapped object @@ -116,16 +133,16 @@ T callApi(final HttpUriRequest request, final Class type) throws SonosApi responseContent = OM.readValue(bytes, sonosDeclaredClass.getClazz()); } catch (final IOException e) { - throw new SonosApiClientException("Unable to parse error response from Sonos", e); + throw new CompletionException(new SonosApiClientException("Unable to parse error response from Sonos", e)); } if (SonosType.getErrorTypes().contains(sonosDeclaredClass)) { - throw (SonosApiError) sonosDeclaredClass.getClazz().cast(responseContent); + throw new CompletionException((SonosApiError) sonosDeclaredClass.getClazz().cast(responseContent)); } else { final String mismatchMsg = String.format("Sonos declared %s as the response type, but the integration requested %s", sonosDeclaredClassName, type.getSimpleName()); - throw new SonosApiClientException(mismatchMsg); + throw new CompletionException(new SonosApiClientException(mismatchMsg)); } } } @@ -136,25 +153,22 @@ T callApi(final HttpUriRequest request, final Class type) throws SonosApi * @param response - raw response to fetch the header from * @return the {@link SonosType} declared, or null if not found */ - SonosType getTypeFromHeader(final CloseableHttpResponse response) throws SonosApiClientException + SonosType getTypeFromHeader(final HttpHeaders headers) throws SonosApiClientException { - if (response != null) + final Optional header = headers.firstValue(SONOS_TYPE_HEADER); + if (header.isPresent() && !SonosUtilityHelper.isEmpty(header.get())) { - final Header header = response.getFirstHeader(SONOS_TYPE_HEADER); - if (header != null && !SonosUtilityHelper.isEmpty(header.getValue())) + final String headerValue = header.get(); + if (!"none".equalsIgnoreCase(headerValue)) { - final String headerValue = header.getValue(); - if (!"none".equalsIgnoreCase(headerValue)) + try { - try - { - return SonosType.valueOf(headerValue); - } - catch (final IllegalArgumentException iae) - { - final String msg = String.format("Unexpected return type [%s] - please raise a bug", headerValue); - throw new SonosApiClientException(msg, iae); - } + return SonosType.valueOf(headerValue); + } + catch (final IllegalArgumentException iae) + { + final String msg = String.format("Unexpected return type [%s] - please raise a bug", headerValue); + throw new SonosApiClientException(msg, iae); } } } @@ -171,11 +185,13 @@ SonosType getTypeFromHeader(final CloseableHttpResponse response) throws SonosAp * @return the response from the Sonos API as the specified type * @throws SonosApiClientException if an error occurs during the call * @throws SonosApiError if the API returns an error + * @throws ExecutionException + * @throws InterruptedException */ - T getFromApi(final Class returnType, final String token, final String path) throws SonosApiClientException, SonosApiError + T getFromApi(final Class returnType, final String token, final String path) + throws SonosApiClientException, SonosApiError { - final HttpGet request = getGetRequest(token, path); - return callApi(request, returnType); + return callApi(buildGetRequest(token, path).build(), returnType); } /** @@ -188,11 +204,13 @@ T getFromApi(final Class returnType, final String token, final String pat * @return the response from the Sonos API as the specified type * @throws SonosApiClientException if an error occurs during the call * @throws SonosApiError if the API returns an error + * @throws ExecutionException + * @throws InterruptedException */ - T deleteFromApi(final Class returnType, final String token, final String path) throws SonosApiClientException, SonosApiError + T deleteFromApi(final Class returnType, final String token, final String path) + throws SonosApiClientException, SonosApiError { - final HttpDelete request = getDeleteRequest(token, path); - return callApi(request, returnType); + return callApi(buildDeleteRequest(token, path).build(), returnType); } /** @@ -205,10 +223,34 @@ T deleteFromApi(final Class returnType, final String token, final String * @return the response from the Sonos API as the specified type * @throws SonosApiClientException if an error occurs during the call * @throws SonosApiError if the API returns an error + * @throws ExecutionException + * @throws InterruptedException */ - T postToApi(final Class returnType, final String token, final String path) throws SonosApiClientException, SonosApiError + T postToApi(final Class returnType, final String token, final String path) + throws SonosApiClientException, SonosApiError + { + try { + return postToApiAsync(returnType, token, path).get(); + } catch (InterruptedException | ExecutionException e) { + Thread.currentThread().interrupt(); + throw new SonosApiClientException(ASYNC_ERROR_MSG, e); + } + } + + CompletableFuture postToApiAsync(final Class returnType, final String token, final String path) throws SonosApiClientException, SonosApiError { - return postToApi(returnType, token, path, new String[0]); + return postToApiAsync(returnType, token, path, null); + } + + T postToApi(final Class returnType, final String token, final String path, + final U content) throws SonosApiClientException, SonosApiError + { + try { + return postToApiAsync(returnType, token, path, content).get(); + } catch (InterruptedException | ExecutionException e) { + Thread.currentThread().interrupt(); + throw new SonosApiClientException(ASYNC_ERROR_MSG, e); + } } /** @@ -224,10 +266,10 @@ T postToApi(final Class returnType, final String token, final String path * @throws SonosApiClientException if an error occurs during the call * @throws SonosApiError if the API returns an error */ - T postToApi(final Class returnType, final String token, final String path, + CompletableFuture postToApiAsync(final Class returnType, final String token, final String path, final U content) throws SonosApiClientException, SonosApiError { - final HttpPost request = getPostRequest(token, path); + final HttpRequest.Builder request = buildPostRequest(token, path); final Boolean validationEnabled = apiClient.getConfiguration().isClientSideValidationEnabled(); // If the content for the request has the ability to be validated, do so if enabled. // If the object is invalid, there's no point sending it to the API to be rejected. @@ -238,18 +280,17 @@ T postToApi(final Class returnType, final String token, final String p if (!SonosUtilityHelper.isEmpty(content)) { final String json; - final StringEntity requestContent; try { json = OM.writeValueAsString(content); - requestContent = new StringEntity(json, ContentType.APPLICATION_JSON); } catch (final JsonProcessingException e) { throw new SonosApiClientException("Unable to convert POST request parameters", e); } - request.setEntity(requestContent); + request.POST(BodyPublishers.ofString(json)); } - return callApi(request, returnType); + + return callApiAsync(request.build(), returnType); } /** @@ -262,33 +303,19 @@ T postToApi(final Class returnType, final String token, final String p * @return a generic request * @throws SonosApiClientException if an error occurs building the request */ - T getStandardRequest(final Class requestType, final String token, + HttpRequest.Builder buildStandardRequest(final HttpRequest.Builder request, final String token, final String path) throws SonosApiClientException { - final T request; - try - { - request = requestType.getDeclaredConstructor().newInstance(); - } catch (final Exception e) - { - throw new SonosApiClientException("Unable to create class " + requestType.getSimpleName(), e); - } - final SonosApiConfiguration configuration = apiClient.getConfiguration(); - final URIBuilder uri = new URIBuilder(); - uri.setScheme("https"); - uri.setHost(configuration.getControlBaseUrl()); - uri.setPath(path); + URI uri; + try { + uri = new URI("https", configuration.getControlBaseUrl(), path, null); + } catch (URISyntaxException e) { + throw new SonosApiClientException("Invalid URI constructed", e); + } request.setHeader("Authorization", String.format("Bearer %s", token)); - - try - { - request.setURI(uri.build()); - } catch (final URISyntaxException e) - { - throw new SonosApiClientException("Invalid URI built", e); - } + request.uri(uri); return request; } @@ -301,9 +328,9 @@ T getStandardRequest(final Class requestType, fin * @return a basic GET request * @throws SonosApiClientException if an error occurs building the request */ - HttpGet getGetRequest(final String token, final String path) throws SonosApiClientException + HttpRequest.Builder buildGetRequest(final String token, final String path) throws SonosApiClientException { - return getStandardRequest(HttpGet.class, token, path); + return buildStandardRequest(HttpRequest.newBuilder().GET(), token, path); } /** @@ -314,9 +341,9 @@ HttpGet getGetRequest(final String token, final String path) throws SonosApiClie * @return a basic DELETE request * @throws SonosApiClientException if an error occurs building the request */ - HttpDelete getDeleteRequest(final String token, final String path) throws SonosApiClientException + HttpRequest.Builder buildDeleteRequest(final String token, final String path) throws SonosApiClientException { - return getStandardRequest(HttpDelete.class, token, path); + return buildStandardRequest(HttpRequest.newBuilder().DELETE(), token, path); } /** @@ -327,9 +354,9 @@ HttpDelete getDeleteRequest(final String token, final String path) throws SonosA * @return a basic POST request * @throws SonosApiClientException if an error occurs building the request */ - HttpPost getPostRequest(final String token, final String path) throws SonosApiClientException + HttpRequest.Builder buildPostRequest(final String token, final String path) throws SonosApiClientException { - return getStandardRequest(HttpPost.class, token, path); + return buildStandardRequest(HttpRequest.newBuilder().POST(BodyPublishers.ofString(null)), token, path); } void validateNotNull(final Object o) throws SonosApiClientException diff --git a/src/main/java/engineer/nightowl/sonos/api/util/SignatureHeader.java b/src/main/java/engineer/nightowl/sonos/api/util/SignatureHeader.java new file mode 100644 index 0000000..9cbf319 --- /dev/null +++ b/src/main/java/engineer/nightowl/sonos/api/util/SignatureHeader.java @@ -0,0 +1,35 @@ +package engineer.nightowl.sonos.api.util; + +import java.util.ArrayList; +import java.util.List; + +public enum SignatureHeader { + SEQUENCE_ID("X-Sonos-Event-Seq-Id"), + NAMESPACE("X-Sonos-Namespace"), + TYPE("X-Sonos-Type"), + TARGET_TYPE("X-Sonos-Target-Type"), + TARGET_VALUE("X-Sonos-Target-Value"); + + private String headerKey; + + SignatureHeader(final String headerKey) + { + this.headerKey = headerKey; + } + + public String getHeaderKey() + { + return headerKey; + } + + public static List getAllHeaders() + { + final List headers = new ArrayList<>(5); + headers.add(SEQUENCE_ID.headerKey); + headers.add(NAMESPACE.headerKey); + headers.add(TYPE.headerKey); + headers.add(TARGET_TYPE.headerKey); + headers.add(TARGET_VALUE.headerKey); + return headers; + } +} diff --git a/src/main/java/engineer/nightowl/sonos/api/util/SonosCallbackHelper.java b/src/main/java/engineer/nightowl/sonos/api/util/SonosCallbackHelper.java index c390996..2932fd1 100644 --- a/src/main/java/engineer/nightowl/sonos/api/util/SonosCallbackHelper.java +++ b/src/main/java/engineer/nightowl/sonos/api/util/SonosCallbackHelper.java @@ -2,24 +2,28 @@ import engineer.nightowl.sonos.api.SonosApiClient; import engineer.nightowl.sonos.api.exception.SonosApiClientException; -import org.apache.http.Header; -import org.apache.http.HttpMessage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.util.Arrays; import java.util.Base64; -import java.util.Map; -import java.util.stream.Collectors; +import java.util.Optional; import static java.nio.charset.StandardCharsets.UTF_8; +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse; + public class SonosCallbackHelper { private static final Logger logger = LoggerFactory.getLogger(SonosCallbackHelper.class); + private SonosCallbackHelper() + { + // Empty private constructor + } + /** * Verify that the message was signed by Sonos. * @@ -29,8 +33,14 @@ public class SonosCallbackHelper * @return true if the message is cryptographically provable to be from Sonos * @throws SonosApiClientException if in an unsupported environment */ - public static Boolean verifySignature(final Map headers, final String apiKey, final String apiSecret) throws SonosApiClientException + public static Boolean verifySignature(final HttpHeaders headers, final String apiKey, final String apiSecret) throws SonosApiClientException { + final Optional sentSignature = headers.firstValue("X-Sonos-Event-Signature"); + if (!sentSignature.isPresent()) + { + throw new SonosApiClientException("No signature present in header, ending early."); + } + MessageDigest messageDigest = null; try { @@ -40,42 +50,35 @@ public static Boolean verifySignature(final Map headers, final S throw new SonosApiClientException("Unsupported execution environment", e); } - messageDigest.update(headers.get("X-Sonos-Event-Seq-Id").getBytes(UTF_8)); - messageDigest.update(headers.get("X-Sonos-Namespace").getBytes(UTF_8)); - messageDigest.update(headers.get("X-Sonos-Type").getBytes(UTF_8)); - messageDigest.update(headers.get("X-Sonos-Target-Type").getBytes(UTF_8)); - messageDigest.update(headers.get("X-Sonos-Target-Value").getBytes(UTF_8)); + if (headers.map().keySet().containsAll(SignatureHeader.getAllHeaders())) + { + for (SignatureHeader key : SignatureHeader.values()) + { + final Optional value = headers.firstValue(key.getHeaderKey()); + if (value.isPresent()) + { + messageDigest.update(value.get().getBytes(UTF_8)); + } + } + } + messageDigest.update(apiKey.getBytes(UTF_8)); - messageDigest.update(apiSecret.getBytes(UTF_8)); + messageDigest.update(apiSecret.getBytes(UTF_8)); final String signature = Base64.getUrlEncoder().withoutPadding().encodeToString(messageDigest.digest()); logger.debug("Verifying signature: {}", signature); - return signature.equals(headers.get("X-Sonos-Event-Signature")); - } - - public static Boolean verifySignature(final Map headers, final SonosApiClient apiClient) throws SonosApiClientException - { - return SonosCallbackHelper.verifySignature(headers, apiClient.getConfiguration().getApiKey(), apiClient.getConfiguration().getApiSecret()); - } - - public static Boolean verifySignature(final HttpMessage message, final SonosApiClient apiClient) throws SonosApiClientException - { - return SonosCallbackHelper.verifySignature(message, apiClient.getConfiguration().getApiKey(), apiClient.getConfiguration().getApiSecret()); + return signature.equals(sentSignature.get()); } - public static Boolean verifySignature(final HttpMessage message, final String apiKey, final String apiSecret) throws SonosApiClientException + public static Boolean verifySignature(final HttpResponse response, final SonosApiClient apiClient) throws SonosApiClientException { - // Map of headers - Name, Value - Map headers = convertHeadersToMap(message.getAllHeaders()); - return SonosCallbackHelper.verifySignature(headers, apiKey, apiSecret); + return SonosCallbackHelper.verifySignature(response.headers(), apiClient.getConfiguration().getApiKey(), apiClient.getConfiguration().getApiSecret()); } - public static Map convertHeadersToMap(final Header[] headers) + public static Boolean verifySignature(final HttpResponse response, final String apiKey, final String apiSecret) throws SonosApiClientException { - return Arrays - .stream(headers) - .collect(Collectors.toMap(Header::getName, Header::getValue)); + return SonosCallbackHelper.verifySignature(response.headers(), apiKey, apiSecret); } } diff --git a/src/test/java/engineer/nightowl/sonos/api/resource/AuthorizeResourceTest.java b/src/test/java/engineer/nightowl/sonos/api/resource/AuthorizeResourceTest.java index e2e30b2..228f6ff 100644 --- a/src/test/java/engineer/nightowl/sonos/api/resource/AuthorizeResourceTest.java +++ b/src/test/java/engineer/nightowl/sonos/api/resource/AuthorizeResourceTest.java @@ -1,8 +1,6 @@ package engineer.nightowl.sonos.api.resource; import engineer.nightowl.sonos.api.BaseTestSetup; -import org.apache.http.NameValuePair; -import org.apache.http.client.utils.URLEncodedUtils; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -10,7 +8,6 @@ import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; -import java.util.HashMap; import java.util.List; class AuthorizeResourceTest extends BaseTestSetup @@ -24,20 +21,19 @@ public void testGetAuthorizeCodeUri() throws URISyntaxException assertEquals("https", uri.getScheme()); } - @Test - public void testGetAuthorizeCodeWithState() throws URISyntaxException - { - final String state = authorizeResource.generateStateValue(); - final URI uri = authorizeResource.getAuthorizeCodeUri("https://localhost", state); - assertEquals("https", uri.getScheme()); - uri.getQuery(); - - final List params = URLEncodedUtils.parse(uri, StandardCharsets.UTF_8); - final HashMap map = new HashMap<>(); - params.forEach(p -> map.put(p.getName(), p.getValue())); - - assertEquals(state, map.get("state")); - assertEquals(configuration.getApiKey(), map.get("client_id")); - } - + // @Test + // public void testGetAuthorizeCodeWithState() throws URISyntaxException + // { + // final String state = authorizeResource.generateStateValue(); + // final URI uri = authorizeResource.getAuthorizeCodeUri("https://localhost", state); + // assertEquals("https", uri.getScheme()); + // uri.getQuery(); + + // final List params = URLEncodedUtils.parse(uri, StandardCharsets.UTF_8); + // final HashMap map = new HashMap<>(); + // params.forEach(p -> map.put(p.getName(), p.getValue())); + + // assertEquals(state, map.get("state")); + // assertEquals(configuration.getApiKey(), map.get("client_id")); + // } } 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 ac8b00a..6e45c8b 100644 --- a/src/test/java/engineer/nightowl/sonos/api/resource/BaseResourceTest.java +++ b/src/test/java/engineer/nightowl/sonos/api/resource/BaseResourceTest.java @@ -1,27 +1,17 @@ package engineer.nightowl.sonos.api.resource; -import com.fasterxml.jackson.databind.ObjectMapper; import engineer.nightowl.sonos.api.SonosApiClient; import engineer.nightowl.sonos.api.SonosApiConfiguration; -import engineer.nightowl.sonos.api.domain.SonosHomeTheaterOptions; -import engineer.nightowl.sonos.api.enums.SonosErrorCode; -import engineer.nightowl.sonos.api.enums.SonosType; import engineer.nightowl.sonos.api.exception.SonosApiClientException; import engineer.nightowl.sonos.api.exception.SonosApiError; -import org.apache.http.HttpEntity; -import org.apache.http.ProtocolVersion; -import org.apache.http.StatusLine; -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.entity.ContentType; -import org.apache.http.entity.StringEntity; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.message.BasicHeader; -import org.apache.http.message.BasicStatusLine; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.mockito.quality.Strictness; -import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -35,28 +25,27 @@ public class BaseResourceTest private static BaseResource baseResource; private static SonosApiClient client; private static SonosApiConfiguration configuration; - private static CloseableHttpClient mockedClient; + private static HttpClient mockedClient; @BeforeAll public static void setUp() throws Exception { client = mock(SonosApiClient.class); - configuration = mock(SonosApiConfiguration.class); - mockedClient = mock(CloseableHttpClient.class); + configuration = new SonosApiConfiguration(); + mockedClient = mock(HttpClient.class); baseResource = new BaseResource(client); when(client.getConfiguration()).thenReturn(configuration); when(client.getHttpClient()).thenReturn(mockedClient); - when(configuration.getControlBaseUrl()).thenReturn("/control/api"); } - @Test +/* @Test void getTypeFromHeader() throws SonosApiClientException { - final CloseableHttpResponse response = mock(CloseableHttpResponse.class); - when(response.getFirstHeader(BaseResource.SONOS_TYPE_HEADER)) - .thenReturn(new BasicHeader(BaseResource.SONOS_TYPE_HEADER, "homeTheaterOptions")); - final SonosType type = baseResource.getTypeFromHeader(response); + final HttpResponse response = (HttpResponse) mock(HttpResponse.class); + when(response.headers().firstValue(BaseResource.SONOS_TYPE_HEADER)) + .thenReturn(Optional.of("homeTheaterOptions")); + final SonosType type = baseResource.getTypeFromHeader(response.headers()); assertEquals(SonosType.homeTheaterOptions, type); } @@ -64,13 +53,13 @@ void getTypeFromHeader() throws SonosApiClientException @Test void getInvalidTypeFromHeader() throws SonosApiClientException { - final CloseableHttpResponse response = mock(CloseableHttpResponse.class); - when(response.getFirstHeader(BaseResource.SONOS_TYPE_HEADER)) - .thenReturn(new BasicHeader(BaseResource.SONOS_TYPE_HEADER, "unexpectedValue")); + final HttpResponse response = (HttpResponse) mock(HttpResponse.class); + when(response.headers().firstValue(BaseResource.SONOS_TYPE_HEADER)) + .thenReturn(Optional.of("unexpectedValue")); try { - baseResource.getTypeFromHeader(response); + baseResource.getTypeFromHeader(response.headers()); fail("Did not fail as expected"); } catch(SonosApiClientException sace) @@ -89,13 +78,12 @@ void testThatMainApiCallWorks() throws IOException, SonosApiClientException, Son options.setGroupingLatency(50); // Mocks - final HttpEntity entity = new StringEntity(new ObjectMapper().writeValueAsString(options), ContentType.APPLICATION_JSON); + final String entity = new ObjectMapper().writeValueAsString(options); - final CloseableHttpResponse mockedResponse = mock(CloseableHttpResponse.class); - when(mockedResponse.getEntity()).thenReturn(entity); - final StatusLine sl = new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, null); - when(mockedResponse.getStatusLine()).thenReturn(sl); - when(client.getHttpClient().execute(any())).thenReturn(mockedResponse); + final HttpResponse mockedResponse = mock(HttpResponse.class); + when(mockedResponse.body().thenReturn(new ByteArrayInputStream(entity.getBytes()))); + when(mockedResponse.statusCode()).thenReturn(200); + when(HttpRequest.newBuilder().GET()(any())).thenReturn(mockedResponse); final SonosHomeTheaterOptions responseOptions = baseResource.getFromApi(SonosHomeTheaterOptions.class, "token123", "some/test"); @@ -202,21 +190,16 @@ void testApiMismatchHandledCorrectly() throws IOException, SonosApiClientExcepti { assertEquals("Sonos declared SonosAudioClip as the response type, but the integration requested SonosHomeTheaterOptions", e.getMessage()); } - } + } */ @Test void getStandardRequest() throws SonosApiClientException { - final HttpGet req = baseResource.getStandardRequest(HttpGet.class, "token123", "/some/path"); - assertEquals(HttpGet.METHOD_NAME, - req.getMethod()); - - assertEquals("Bearer token123", - req.getFirstHeader("Authorization").getValue()); + final HttpRequest req = baseResource.buildStandardRequest(HttpRequest.newBuilder(), "token123", "/some/path").build(); - assertEquals( - "/control/api/some/path", - req.getURI().getPath()); + assertEquals("GET", req.method()); + assertEquals("Bearer token123", req.headers().firstValue("Authorization").get()); + assertEquals("/control/api/some/path", req.uri().getPath()); } @Test diff --git a/src/test/java/engineer/nightowl/sonos/api/util/HeaderFilter.java b/src/test/java/engineer/nightowl/sonos/api/util/HeaderFilter.java new file mode 100644 index 0000000..1ede05c --- /dev/null +++ b/src/test/java/engineer/nightowl/sonos/api/util/HeaderFilter.java @@ -0,0 +1,12 @@ +package engineer.nightowl.sonos.api.util; + +import java.util.function.BiPredicate; + +public class HeaderFilter implements BiPredicate{ + + @Override + public boolean test(String t, String u) { + return true; + } + +} diff --git a/src/test/java/engineer/nightowl/sonos/api/util/SonosCallbackHelperTest.java b/src/test/java/engineer/nightowl/sonos/api/util/SonosCallbackHelperTest.java index b72db0e..1af8406 100644 --- a/src/test/java/engineer/nightowl/sonos/api/util/SonosCallbackHelperTest.java +++ b/src/test/java/engineer/nightowl/sonos/api/util/SonosCallbackHelperTest.java @@ -1,14 +1,15 @@ package engineer.nightowl.sonos.api.util; import engineer.nightowl.sonos.api.exception.SonosApiClientException; -import org.apache.http.entity.ContentType; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.net.http.HttpHeaders; import java.security.NoSuchAlgorithmException; import java.util.HashMap; +import java.util.List; import java.util.Map; class SonosCallbackHelperTest @@ -16,32 +17,34 @@ class SonosCallbackHelperTest final String apiKey = "sonos"; final String apiSecret = "secret"; - private Map getHeaders() + private HttpHeaders getHeaders() { - final Map headers = new HashMap<>(); - headers.put("X-Sonos-Event-Seq-Id", "event-seq-id"); - headers.put("X-Sonos-Namespace", "namespace"); - headers.put("X-Sonos-Type", "type"); - headers.put("X-Sonos-Target-Type", "target-type"); - headers.put("X-Sonos-Target-Value", "target-value"); - headers.put("Unrelated-Header", "sonos123"); - headers.put("Content-Type", ContentType.APPLICATION_JSON.getMimeType()); - - return headers; + final Map> headers = new HashMap<>(); + headers.put("X-Sonos-Event-Seq-Id", List.of("event-seq-id")); + headers.put("X-Sonos-Namespace", List.of("namespace")); + headers.put("X-Sonos-Type", List.of("type")); + headers.put("X-Sonos-Target-Type", List.of("target-type")); + headers.put("X-Sonos-Target-Value", List.of("target-value")); + headers.put("Unrelated-Header", List.of("sonos123")); + headers.put("Content-Type", List.of("application/json")); + + return HttpHeaders.of(headers, new HeaderFilter()); } - private Map getValidHeaders() + private HttpHeaders getValidHeaders() { - final Map headers = getHeaders(); - headers.put("X-Sonos-Event-Signature", "n9V0MCdGYaOPy_dJb_nUPqw5dHiBSd_g0NOQTe4IaVI"); - return headers; + final HttpHeaders headers = getHeaders(); + final Map> copiedHeaders = new HashMap<>(headers.map()); + copiedHeaders.put("X-Sonos-Event-Signature", List.of("n9V0MCdGYaOPy_dJb_nUPqw5dHiBSd_g0NOQTe4IaVI")); + return HttpHeaders.of(copiedHeaders, new HeaderFilter()); } - private Map getInvalidHeaders() + private HttpHeaders getInvalidHeaders() { - final Map headers = getHeaders(); - headers.put("X-Sonos-Event-Signature", "invalidSignature"); - return headers; + final HttpHeaders headers = getHeaders(); + final Map> copiedHeaders = new HashMap<>(headers.map()); + copiedHeaders.put("X-Sonos-Event-Signature", List.of("invalidSignature")); + return HttpHeaders.of(copiedHeaders, new HeaderFilter()); } @Test