2 * Copyright (c) 2010-2020 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.bticinosmarther.internal.api;
15 import static org.eclipse.jetty.http.HttpStatus.*;
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;
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;
37 import com.google.gson.JsonElement;
38 import com.google.gson.JsonObject;
39 import com.google.gson.JsonParser;
40 import com.google.gson.JsonSyntaxException;
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.
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>
59 * @author Fabio Possieri - Initial contribution
62 public class SmartherApiConnector {
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";
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";
73 private static final int HTTP_CLIENT_TIMEOUT_SECONDS = 10;
74 private static final int HTTP_CLIENT_RETRY_COUNT = 5;
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;
85 private final Logger logger = LoggerFactory.getLogger(SmartherApiConnector.class);
87 private final JsonParser parser = new JsonParser();
88 private final HttpClient httpClient;
89 private final ScheduledExecutorService scheduler;
92 * Constructs a {@code SmartherApiConnector} to the API gateway with the specified scheduler and http client.
95 * the scheduler to be used to reschedule calls when rate limit exceeded or call not succeeded
97 * the http client to be used to make http calls to the API gateway
99 public SmartherApiConnector(ScheduledExecutorService scheduler, HttpClient httpClient) {
100 this.scheduler = scheduler;
101 this.httpClient = httpClient;
105 * Performs a call to the API gateway and returns the raw response.
108 * the function to construct the request, using the http client that is passed as argument to the
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
115 * @return the raw response returned by the API gateway
117 * @throws {@link SmartherGatewayException}
118 * if the call failed due to an issue with the API gateway
120 public ContentResponse request(Function<HttpClient, Request> requester, String subscription, String authorization)
121 throws SmartherGatewayException {
122 final Caller caller = new Caller(requester, subscription, authorization);
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();
132 if (cause instanceof SmartherGatewayException) {
133 throw (SmartherGatewayException) cause;
135 throw new SmartherGatewayException(e.getMessage(), e);
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.
145 * @author Fabio Possieri - Initial contribution
147 private class Caller {
148 private final Function<HttpClient, Request> requester;
149 private final String subscription;
150 private final String authorization;
152 private final CompletableFuture<ContentResponse> future = new CompletableFuture<>();
153 private int delaySeconds;
154 private int attempts;
157 * Constructs a {@code Caller} to the API gateway with the specified requester, subscription and authorization.
160 * the function to construct the request, using the http client that is passed as argument to the
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
167 public Caller(Function<HttpClient, Request> requester, String subscription, String authorization) {
168 this.requester = requester;
169 this.subscription = subscription;
170 this.authorization = authorization;
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.
179 * @return the {@link CompletableFuture} holding the call
181 public CompletableFuture<ContentResponse> call() {
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());
189 if (attempts < HTTP_CLIENT_RETRY_COUNT) {
190 logger.debug("API Gateway call attempt: {}", attempts);
192 scheduler.schedule(this::call, delaySeconds, TimeUnit.SECONDS);
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)));
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);
214 * Processes the response from the API gateway call and handles the http status codes.
217 * the response content returned by the API gateway
219 * @return {@code true} if the call was successful, {@code false} if the call failed in a way that can be
222 * @throws {@link SmartherGatewayException}
223 * if the call failed due to an irrecoverable issue and cannot be retried (user should be informed)
225 private boolean processResponse(ContentResponse response) throws SmartherGatewayException {
226 boolean success = false;
228 logger.debug("Response Code: {}", response.getStatus());
229 if (logger.isTraceEnabled()) {
230 logger.trace("Response Data: {}", response.getContentAsString());
232 switch (response.getStatus()) {
236 case NOT_MODIFIED_304:
237 future.complete(response);
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);
249 // Process for authorization error, and logging.
250 processErrorState(response);
251 future.complete(response);
255 case BAD_REQUEST_400:
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));
265 case UNAUTHORIZED_401:
266 throw new SmartherAuthorizationException(processErrorState(response));
269 // Subscribe to C2C notifications > Subscription already exists.
270 throw new SmartherSubscriptionAlreadyExistsException(processErrorState(response));
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);
276 "API Gateway returned error status 429 (rate limit exceeded - retry after {} seconds, decrease polling interval of bridge, going to sleep...)",
278 delaySeconds = Integer.parseInt(retryAfter);
281 case BAD_GATEWAY_502:
282 case SERVICE_UNAVAILABLE_503:
284 throw new SmartherGatewayException(String.format("API Gateway returned error status %s (%s)",
285 response.getStatus(), HttpStatus.getMessage(response.getStatus())));
291 * Processes the responded content if the status code indicated an error.
294 * the response content returned by the API gateway
296 * @return the error message extracted from the response content
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
305 private String processErrorState(ContentResponse response)
306 throws SmartherTokenExpiredException, SmartherAuthorizationException, SmartherInvalidResponseException {
308 final JsonElement element = parser.parse(response.getContentAsString());
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();
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);
323 } else if (object.has(AUTHORIZATION_ERROR)) {
324 final String errorDescription = object.get(AUTHORIZATION_ERROR).getAsString();
325 throw new SmartherAuthorizationException(errorDescription);
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());