]> git.basschouten.com Git - openhab-addons.git/blob
f61dc915a2d870a4b40f081a37666fe70f091b65
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.spotify.internal.api;
14
15 import static org.eclipse.jetty.http.HttpStatus.*;
16
17 import java.util.concurrent.CompletableFuture;
18 import java.util.concurrent.ExecutionException;
19 import java.util.concurrent.ScheduledExecutorService;
20 import java.util.concurrent.TimeUnit;
21 import java.util.concurrent.TimeoutException;
22 import java.util.function.Function;
23
24 import org.eclipse.jdt.annotation.NonNullByDefault;
25 import org.eclipse.jetty.client.HttpClient;
26 import org.eclipse.jetty.client.api.ContentResponse;
27 import org.eclipse.jetty.client.api.Request;
28 import org.openhab.binding.spotify.internal.api.exception.SpotifyAuthorizationException;
29 import org.openhab.binding.spotify.internal.api.exception.SpotifyException;
30 import org.openhab.binding.spotify.internal.api.exception.SpotifyTokenExpiredException;
31 import org.slf4j.Logger;
32 import org.slf4j.LoggerFactory;
33
34 import com.google.gson.JsonElement;
35 import com.google.gson.JsonObject;
36 import com.google.gson.JsonParser;
37 import com.google.gson.JsonSyntaxException;
38
39 /**
40  * Class to perform the actual call to the Spotify Api, interprets the returned Http status codes, and handles the error
41  * codes returned by the Spotify Web Api.
42  *
43  * @author Hilbrand Bouwkamp - Initial contribution
44  */
45 @NonNullByDefault
46 class SpotifyConnector {
47
48     private static final String RETRY_AFTER_HEADER = "Retry-After";
49     private static final String AUTHORIZATION_HEADER = "Authorization";
50
51     private static final int HTTP_CLIENT_TIMEOUT_SECONDS = 10;
52     private static final int HTTP_CLIENT_RETRY_COUNT = 5;
53
54     private final Logger logger = LoggerFactory.getLogger(SpotifyConnector.class);
55
56     private final JsonParser parser = new JsonParser();
57     private final HttpClient httpClient;
58     private final ScheduledExecutorService scheduler;
59
60     /**
61      * Constructor.
62      *
63      * @param scheduler Scheduler to reschedule calls when rate limit exceeded or call not ready
64      * @param httpClient http client to use to make http calls
65      */
66     public SpotifyConnector(ScheduledExecutorService scheduler, HttpClient httpClient) {
67         this.scheduler = scheduler;
68         this.httpClient = httpClient;
69     }
70
71     /**
72      * Performs a call to the Spotify Web Api and returns the raw response. In there are problems this method can throw
73      * a Spotify exception.
74      *
75      * @param requester The function to construct the request with http client that is passed as argument to the
76      *            function
77      * @param authorization The authorization string to use in the Authorization header
78      * @return the raw reponse given
79      */
80     public ContentResponse request(Function<HttpClient, Request> requester, String authorization) {
81         final Caller caller = new Caller(requester, authorization);
82
83         try {
84             return caller.call().get();
85         } catch (InterruptedException e) {
86             Thread.currentThread().interrupt();
87             throw new SpotifyException("Thread interrupted");
88         } catch (ExecutionException e) {
89             final Throwable cause = e.getCause();
90
91             if (cause instanceof RuntimeException) {
92                 throw (RuntimeException) cause;
93             } else {
94                 throw new SpotifyException(e.getMessage(), e);
95             }
96         }
97     }
98
99     /**
100      * Class to handle a call to the Spotify Web Api. In case of rate limiting or not finished jobs it will retry in a
101      * specified time frame. It retries a number of times and then gives up with an exception.
102      *
103      * @author Hilbrand Bouwkamp - Initial contribution
104      */
105     private class Caller {
106         private final Function<HttpClient, Request> requester;
107         private final String authorization;
108
109         private final CompletableFuture<ContentResponse> future = new CompletableFuture<>();
110         private int delaySeconds;
111         private int attempts;
112
113         /**
114          * Constructor.
115          *
116          * @param requester The function to construct the request with http client that is passed as argument to the
117          *            function
118          * @param authorization The authorization string to use in the Authorization header
119          */
120         public Caller(Function<HttpClient, Request> requester, String authorization) {
121             this.requester = requester;
122             this.authorization = authorization;
123         }
124
125         /**
126          * Performs the request as a Future. It will set the Future state once it's finished. This method will be
127          * scheduled again when the call is to be retried. The original caller should call the get method on the Future
128          * to wait for the call to finish. The first try is not scheduled so if it succeeds on the first call the get
129          * method directly returns the value.
130          *
131          * @return the Future holding the call
132          */
133         public CompletableFuture<ContentResponse> call() {
134             attempts++;
135             try {
136                 final boolean success = processResponse(
137                         requester.apply(httpClient).header(AUTHORIZATION_HEADER, authorization)
138                                 .timeout(HTTP_CLIENT_TIMEOUT_SECONDS, TimeUnit.SECONDS).send());
139
140                 if (!success) {
141                     if (attempts < HTTP_CLIENT_RETRY_COUNT) {
142                         logger.debug("Spotify Web API call attempt: {}", attempts);
143
144                         scheduler.schedule(this::call, delaySeconds, TimeUnit.SECONDS);
145                     } else {
146                         logger.debug("Giving up on accessing Spotify Web API. Check network connectivity!");
147                         future.completeExceptionally(new SpotifyException(
148                                 "Could not reach the Spotify Web Api after " + attempts + " retries."));
149                     }
150                 }
151             } catch (ExecutionException e) {
152                 Throwable cause = e.getCause();
153                 if (cause != null) {
154                     future.completeExceptionally(cause);
155                 } else {
156                     future.completeExceptionally(e);
157                 }
158             } catch (RuntimeException | TimeoutException e) {
159                 future.completeExceptionally(e);
160             } catch (InterruptedException e) {
161                 Thread.currentThread().interrupt();
162                 future.completeExceptionally(e);
163             }
164             return future;
165         }
166
167         /**
168          * Processes the response of the Spotify Web Api call and handles the HTTP status codes. The method returns true
169          * if the response indicates a successful and false if the call should be retried. If there were other problems
170          * a Spotify exception is thrown indicating no retry should be done an the user should be informed.
171          *
172          * @param response the response given by the Spotify Web Api
173          * @return true if the response indicated a successful call, false if the call should be retried
174          */
175         private boolean processResponse(ContentResponse response) {
176             boolean success = false;
177
178             logger.debug("Response Code: {}", response.getStatus());
179             if (logger.isTraceEnabled()) {
180                 logger.trace("Response Data: {}", response.getContentAsString());
181             }
182             switch (response.getStatus()) {
183                 case OK_200:
184                 case CREATED_201:
185                 case NO_CONTENT_204:
186                 case NOT_MODIFIED_304:
187                     future.complete(response);
188                     success = true;
189                     break;
190                 case ACCEPTED_202:
191                     logger.debug(
192                             "Spotify Web API returned code 202 - The request has been accepted for processing, but the processing has not been completed.");
193                     future.complete(response);
194                     success = true;
195                     break;
196                 case BAD_REQUEST_400:
197                     throw new SpotifyException(processErrorState(response));
198                 case UNAUTHORIZED_401:
199                     throw new SpotifyAuthorizationException(processErrorState(response));
200                 case TOO_MANY_REQUESTS_429:
201                     // Response Code 429 means requests rate limits exceeded.
202                     final String retryAfter = response.getHeaders().get(RETRY_AFTER_HEADER);
203
204                     logger.debug(
205                             "Spotify Web API returned code 429 (rate limit exceeded). Retry After {} seconds. Decrease polling interval of bridge! Going to sleep...",
206                             retryAfter);
207                     delaySeconds = Integer.parseInt(retryAfter);
208                     break;
209                 case FORBIDDEN_403:
210                     // Process for authorization error, and logging.
211                     processErrorState(response);
212                     future.complete(response);
213                     success = true;
214                     break;
215                 case NOT_FOUND_404:
216                     throw new SpotifyException(processErrorState(response));
217                 case SERVICE_UNAVAILABLE_503:
218                 case INTERNAL_SERVER_ERROR_500:
219                 case BAD_GATEWAY_502:
220                 default:
221                     throw new SpotifyException("Spotify returned with error status: " + response.getStatus());
222             }
223             return success;
224         }
225
226         /**
227          * Processes the responded content if the status code indicated an error. If the response could be parsed the
228          * content error message is returned. If the error indicated a token or authorization error a specific exception
229          * is thrown. If an error message is thrown the caller throws the appropriate exception based on the state with
230          * which the error was returned by the Spotify Web Api.
231          *
232          * @param response content returned by Spotify Web Api
233          * @return the error messages
234          */
235         private String processErrorState(ContentResponse response) {
236             try {
237                 final JsonElement element = parser.parse(response.getContentAsString());
238
239                 if (element.isJsonObject()) {
240                     final JsonObject object = element.getAsJsonObject();
241                     if (object.has("error") && object.get("error").isJsonObject()) {
242                         final String message = object.get("error").getAsJsonObject().get("message").getAsString();
243
244                         // Bad request can be anything, from authorization problems to start play problems.
245                         // Therefore authorization type errors are filtered and handled differently.
246                         logger.debug("Bad request: {}", message);
247                         if (message.contains("expired")) {
248                             throw new SpotifyTokenExpiredException(message);
249                         } else {
250                             return message;
251                         }
252                     } else if (object.has("error_description")) {
253                         final String errorDescription = object.get("error_description").getAsString();
254
255                         throw new SpotifyAuthorizationException(errorDescription);
256                     }
257                 }
258                 logger.debug("Unknown response: {}", response);
259                 return "Unknown response";
260             } catch (JsonSyntaxException e) {
261                 logger.debug("Response was not json: ", e);
262                 return "Unknown response";
263             }
264         }
265     }
266 }