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