]> git.basschouten.com Git - openhab-addons.git/blob
34e6c0215c7a47eaaf0fe2205b174120cca069a7
[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.bticinosmarther.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.eclipse.jetty.http.HttpStatus;
29 import org.openhab.binding.bticinosmarther.internal.api.exception.SmartherAuthorizationException;
30 import org.openhab.binding.bticinosmarther.internal.api.exception.SmartherGatewayException;
31 import org.openhab.binding.bticinosmarther.internal.api.exception.SmartherInvalidResponseException;
32 import org.openhab.binding.bticinosmarther.internal.api.exception.SmartherSubscriptionAlreadyExistsException;
33 import org.openhab.binding.bticinosmarther.internal.api.exception.SmartherTokenExpiredException;
34 import org.slf4j.Logger;
35 import org.slf4j.LoggerFactory;
36
37 import com.google.gson.JsonElement;
38 import com.google.gson.JsonObject;
39 import com.google.gson.JsonParser;
40 import com.google.gson.JsonSyntaxException;
41
42 /**
43  * The {@code SmartherApiConnector} class is used to perform the actual call to the API gateway.
44  * It handles the returned http status codes and the error codes eventually returned by the API gateway itself.
45  *
46  * Response mappings:
47  * <ul>
48  * <li>Plants : 200, 204, 400, 401, 404, 408, 469, 470, 500</li>
49  * <li>Topology : 200, 400, 401, 404, 408, 469, 470, 500</li>
50  * <li>Measures : 200, 400, 401, 404, 408, 469, 470, 500</li>
51  * <li>ProgramList : 200, 400, 401, 404, 408, 469, 470, 500</li>
52  * <li>Get Status : 200, 400, 401, 404, 408, 469, 470, 500</li>
53  * <li>Set Status : 200, 400, 401, 404, 408, 430, 469, 470, 486, 500</li>
54  * <li>Get Subscriptions : 200, 204, 400, 401, 404, 500</li>
55  * <li>Subscribe : 201, 400, 401, 404, 409, 500</li>
56  * <li>Delete Subscription : 200, 400, 401, 404, 500</li>
57  * </ul>
58  *
59  * @author Fabio Possieri - Initial contribution
60  */
61 @NonNullByDefault
62 public class SmartherApiConnector {
63
64     private static final String RETRY_AFTER_HEADER = "Retry-After";
65     private static final String AUTHORIZATION_HEADER = "Authorization";
66     private static final String SUBSCRIPTION_HEADER = "Ocp-Apim-Subscription-Key";
67
68     private static final String ERROR_CODE = "statusCode";
69     private static final String ERROR_MESSAGE = "message";
70     private static final String TOKEN_EXPIRED = "expired";
71     private static final String AUTHORIZATION_ERROR = "error_description";
72
73     private static final int HTTP_CLIENT_TIMEOUT_SECONDS = 10;
74     private static final int HTTP_CLIENT_RETRY_COUNT = 5;
75
76     // Set Chronothermostat Status > Wrong input parameters
77     private static final int WRONG_INPUT_PARAMS_430 = 430;
78     // Official application password expired: password used in the Thermostat official app is expired.
79     private static final int APP_PASSWORD_EXPIRED_469 = 469;
80     // Official application terms and conditions expired: terms and conditions for Thermostat official app are expired.
81     private static final int APP_TERMS_EXPIRED_470 = 470;
82     // Set Chronothermostat Status > Busy visual user interface
83     private static final int BUSY_VISUAL_UI_486 = 486;
84
85     private final Logger logger = LoggerFactory.getLogger(SmartherApiConnector.class);
86
87     private final JsonParser parser = new JsonParser();
88     private final HttpClient httpClient;
89     private final ScheduledExecutorService scheduler;
90
91     /**
92      * Constructs a {@code SmartherApiConnector} to the API gateway with the specified scheduler and http client.
93      *
94      * @param scheduler
95      *            the scheduler to be used to reschedule calls when rate limit exceeded or call not succeeded
96      * @param httpClient
97      *            the http client to be used to make http calls to the API gateway
98      */
99     public SmartherApiConnector(ScheduledExecutorService scheduler, HttpClient httpClient) {
100         this.scheduler = scheduler;
101         this.httpClient = httpClient;
102     }
103
104     /**
105      * Performs a call to the API gateway and returns the raw response.
106      *
107      * @param requester
108      *            the function to construct the request, using the http client that is passed as argument to the
109      *            function itself
110      * @param subscription
111      *            the subscription string to be used in the call {@code Subscription} header
112      * @param authorization
113      *            the authorization string to be used in the call {@code Authorization} header
114      *
115      * @return the raw response returned by the API gateway
116      *
117      * @throws {@link SmartherGatewayException}
118      *             if the call failed due to an issue with the API gateway
119      */
120     public ContentResponse request(Function<HttpClient, Request> requester, String subscription, String authorization)
121             throws SmartherGatewayException {
122         final Caller caller = new Caller(requester, subscription, authorization);
123
124         try {
125             return caller.call().get();
126         } catch (InterruptedException e) {
127             Thread.currentThread().interrupt();
128             throw new SmartherGatewayException("Thread interrupted");
129         } catch (ExecutionException e) {
130             final Throwable cause = e.getCause();
131
132             if (cause instanceof SmartherGatewayException) {
133                 throw (SmartherGatewayException) cause;
134             } else {
135                 throw new SmartherGatewayException(e.getMessage(), e);
136             }
137         }
138     }
139
140     /**
141      * The {@code Caller} class represents the handler to make calls to the API gateway.
142      * In case of rate limiting or not finished jobs, it will retry a number of times in a specified timeframe then
143      * gives up with an exception.
144      *
145      * @author Fabio Possieri - Initial contribution
146      */
147     private class Caller {
148         private final Function<HttpClient, Request> requester;
149         private final String subscription;
150         private final String authorization;
151
152         private final CompletableFuture<ContentResponse> future = new CompletableFuture<>();
153         private int delaySeconds;
154         private int attempts;
155
156         /**
157          * Constructs a {@code Caller} to the API gateway with the specified requester, subscription and authorization.
158          *
159          * @param requester
160          *            the function to construct the request, using the http client that is passed as argument to the
161          *            function itself
162          * @param subscription
163          *            the subscription string to be used in the call {@code Subscription} header
164          * @param authorization
165          *            the authorization string to be used in the call {@code Authorization} header
166          */
167         public Caller(Function<HttpClient, Request> requester, String subscription, String authorization) {
168             this.requester = requester;
169             this.subscription = subscription;
170             this.authorization = authorization;
171         }
172
173         /**
174          * Performs the request as a {@link CompletableFuture}, setting its state once finished.
175          * The original caller should call the {@code get} method on the Future to wait for the call to finish.
176          * The first attempt is not scheduled so, if the first call succeeds, the {@code get} method directly returns
177          * the value. This method is rescheduled in case the call is to be retried.
178          *
179          * @return the {@link CompletableFuture} holding the call
180          */
181         public CompletableFuture<ContentResponse> call() {
182             attempts++;
183             try {
184                 final boolean success = processResponse(requester.apply(httpClient)
185                         .header(SUBSCRIPTION_HEADER, subscription).header(AUTHORIZATION_HEADER, authorization)
186                         .timeout(HTTP_CLIENT_TIMEOUT_SECONDS, TimeUnit.SECONDS).send());
187
188                 if (!success) {
189                     if (attempts < HTTP_CLIENT_RETRY_COUNT) {
190                         logger.debug("API Gateway call attempt: {}", attempts);
191
192                         scheduler.schedule(this::call, delaySeconds, TimeUnit.SECONDS);
193                     } else {
194                         logger.debug("Giving up on accessing API Gateway. Check network connectivity!");
195                         future.completeExceptionally(new SmartherGatewayException(
196                                 String.format("Could not reach the API Gateway after %s retries.", attempts)));
197                     }
198                 }
199             } catch (ExecutionException e) {
200                 Throwable cause = e.getCause();
201                 future.completeExceptionally(cause != null ? cause : e);
202             } catch (SmartherGatewayException e) {
203                 future.completeExceptionally(e);
204             } catch (RuntimeException | TimeoutException e) {
205                 future.completeExceptionally(e);
206             } catch (InterruptedException e) {
207                 Thread.currentThread().interrupt();
208                 future.completeExceptionally(e);
209             }
210             return future;
211         }
212
213         /**
214          * Processes the response from the API gateway call and handles the http status codes.
215          *
216          * @param response
217          *            the response content returned by the API gateway
218          *
219          * @return {@code true} if the call was successful, {@code false} if the call failed in a way that can be
220          *         retried
221          *
222          * @throws {@link SmartherGatewayException}
223          *             if the call failed due to an irrecoverable issue and cannot be retried (user should be informed)
224          */
225         private boolean processResponse(ContentResponse response) throws SmartherGatewayException {
226             boolean success = false;
227
228             logger.debug("Response Code: {}", response.getStatus());
229             if (logger.isTraceEnabled()) {
230                 logger.trace("Response Data: {}", response.getContentAsString());
231             }
232             switch (response.getStatus()) {
233                 case OK_200:
234                 case CREATED_201:
235                 case NO_CONTENT_204:
236                 case NOT_MODIFIED_304:
237                     future.complete(response);
238                     success = true;
239                     break;
240
241                 case ACCEPTED_202:
242                     logger.debug(
243                             "API Gateway returned error status 202 (the request has been accepted for processing, but the processing has not been completed)");
244                     future.complete(response);
245                     success = true;
246                     break;
247
248                 case FORBIDDEN_403:
249                     // Process for authorization error, and logging.
250                     processErrorState(response);
251                     future.complete(response);
252                     success = true;
253                     break;
254
255                 case BAD_REQUEST_400:
256                 case NOT_FOUND_404:
257                 case REQUEST_TIMEOUT_408:
258                 case WRONG_INPUT_PARAMS_430:
259                 case APP_PASSWORD_EXPIRED_469:
260                 case APP_TERMS_EXPIRED_470:
261                 case BUSY_VISUAL_UI_486:
262                 case INTERNAL_SERVER_ERROR_500:
263                     throw new SmartherGatewayException(processErrorState(response));
264
265                 case UNAUTHORIZED_401:
266                     throw new SmartherAuthorizationException(processErrorState(response));
267
268                 case CONFLICT_409:
269                     // Subscribe to C2C notifications > Subscription already exists.
270                     throw new SmartherSubscriptionAlreadyExistsException(processErrorState(response));
271
272                 case TOO_MANY_REQUESTS_429:
273                     // Response Code 429 means requests rate limits exceeded.
274                     final String retryAfter = response.getHeaders().get(RETRY_AFTER_HEADER);
275                     logger.debug(
276                             "API Gateway returned error status 429 (rate limit exceeded - retry after {} seconds, decrease polling interval of bridge, going to sleep...)",
277                             retryAfter);
278                     delaySeconds = Integer.parseInt(retryAfter);
279                     break;
280
281                 case BAD_GATEWAY_502:
282                 case SERVICE_UNAVAILABLE_503:
283                 default:
284                     throw new SmartherGatewayException(String.format("API Gateway returned error status %s (%s)",
285                             response.getStatus(), HttpStatus.getMessage(response.getStatus())));
286             }
287             return success;
288         }
289
290         /**
291          * Processes the responded content if the status code indicated an error.
292          *
293          * @param response
294          *            the response content returned by the API gateway
295          *
296          * @return the error message extracted from the response content
297          *
298          * @throws {@link SmartherTokenExpiredException}
299          *             if the authorization access token used to communicate with the API gateway has expired
300          * @throws {@link SmartherAuthorizationException}
301          *             if a generic authorization issue with the API gateway has occurred
302          * @throws {@link SmartherInvalidResponseException}
303          *             if the response received from the API gateway cannot be parsed
304          */
305         private String processErrorState(ContentResponse response)
306                 throws SmartherTokenExpiredException, SmartherAuthorizationException, SmartherInvalidResponseException {
307             try {
308                 final JsonElement element = parser.parse(response.getContentAsString());
309
310                 if (element.isJsonObject()) {
311                     final JsonObject object = element.getAsJsonObject();
312                     if (object.has(ERROR_CODE) && object.has(ERROR_MESSAGE)) {
313                         final String message = object.get(ERROR_MESSAGE).getAsString();
314
315                         // Bad request can be anything, from authorization problems to plant or module problems.
316                         // Therefore authorization type errors are filtered and handled differently.
317                         logger.debug("Bad request: {}", message);
318                         if (message.contains(TOKEN_EXPIRED)) {
319                             throw new SmartherTokenExpiredException(message);
320                         } else {
321                             return message;
322                         }
323                     } else if (object.has(AUTHORIZATION_ERROR)) {
324                         final String errorDescription = object.get(AUTHORIZATION_ERROR).getAsString();
325                         throw new SmartherAuthorizationException(errorDescription);
326                     }
327                 }
328                 logger.debug("Unknown response: {}", response);
329                 return "Unknown response";
330             } catch (JsonSyntaxException e) {
331                 logger.warn("Response was not json: ", e);
332                 throw new SmartherInvalidResponseException(e.getMessage());
333             }
334         }
335     }
336 }