]> git.basschouten.com Git - openhab-addons.git/blob
5cd191307ba7bba2e95f9967b02efe3048b10cbf
[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.boschindego.internal;
14
15 import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*;
16
17 import java.io.IOException;
18 import java.time.Instant;
19 import java.util.Arrays;
20 import java.util.Collection;
21 import java.util.concurrent.ExecutionException;
22 import java.util.concurrent.TimeoutException;
23
24 import org.eclipse.jdt.annotation.NonNullByDefault;
25 import org.eclipse.jdt.annotation.Nullable;
26 import org.eclipse.jetty.client.HttpClient;
27 import org.eclipse.jetty.client.HttpResponseException;
28 import org.eclipse.jetty.client.api.ContentResponse;
29 import org.eclipse.jetty.client.api.Request;
30 import org.eclipse.jetty.client.api.Response;
31 import org.eclipse.jetty.client.util.StringContentProvider;
32 import org.eclipse.jetty.http.HttpHeader;
33 import org.eclipse.jetty.http.HttpMethod;
34 import org.eclipse.jetty.http.HttpStatus;
35 import org.openhab.binding.boschindego.internal.dto.response.DevicePropertiesResponse;
36 import org.openhab.binding.boschindego.internal.dto.response.ErrorResponse;
37 import org.openhab.binding.boschindego.internal.dto.response.Mower;
38 import org.openhab.binding.boschindego.internal.dto.serialization.InstantDeserializer;
39 import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException;
40 import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
41 import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException;
42 import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidResponseException;
43 import org.openhab.binding.boschindego.internal.exceptions.IndegoTimeoutException;
44 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
45 import org.openhab.core.auth.client.oauth2.OAuthClientService;
46 import org.openhab.core.auth.client.oauth2.OAuthException;
47 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
48 import org.openhab.core.library.types.RawType;
49 import org.osgi.framework.FrameworkUtil;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
52
53 import com.google.gson.Gson;
54 import com.google.gson.GsonBuilder;
55 import com.google.gson.JsonParseException;
56
57 /**
58  * Controller for communicating with a Bosch Indego services.
59  * 
60  * @author Jacob Laursen - Initial contribution
61  */
62 @NonNullByDefault
63 public class IndegoController {
64
65     protected static final String SERIAL_NUMBER_SUBPATH = "alms/";
66
67     private static final String BASE_URL = "https://api.indego-cloud.iot.bosch-si.com/api/v1/";
68     private static final String CONTENT_TYPE_HEADER = "application/json";
69     private static final String BEARER = "Bearer ";
70
71     private final Logger logger = LoggerFactory.getLogger(IndegoController.class);
72     private final Gson gson = new GsonBuilder().registerTypeAdapter(Instant.class, new InstantDeserializer()).create();
73     private final HttpClient httpClient;
74     private final OAuthClientService oAuthClientService;
75     private final String userAgent;
76
77     /**
78      * Initialize the controller instance.
79      * 
80      * @param httpClient the HttpClient for communicating with the service
81      * @param oAuthClientService the OAuthClientService for authorization
82      */
83     public IndegoController(HttpClient httpClient, OAuthClientService oAuthClientService) {
84         this.httpClient = httpClient;
85         this.oAuthClientService = oAuthClientService;
86         userAgent = "openHAB/" + FrameworkUtil.getBundle(this.getClass()).getVersion().toString();
87     }
88
89     /**
90      * Gets serial numbers of all the associated Indego devices.
91      *
92      * @return the serial numbers of the devices
93      * @throws IndegoAuthenticationException if request was rejected as unauthorized
94      * @throws IndegoException if any communication or parsing error occurred
95      */
96     public Collection<String> getSerialNumbers() throws IndegoAuthenticationException, IndegoException {
97         Mower[] mowers = getRequest(SERIAL_NUMBER_SUBPATH, Mower[].class);
98
99         return Arrays.stream(mowers).map(m -> m.serialNumber).toList();
100     }
101
102     /**
103      * Queries the serial number and device service properties from the server.
104      *
105      * @param serialNumber the serial number of the device
106      * @return the device serial number and properties
107      * @throws IndegoAuthenticationException if request was rejected as unauthorized
108      * @throws IndegoException if any communication or parsing error occurred
109      */
110     public DevicePropertiesResponse getDeviceProperties(String serialNumber)
111             throws IndegoAuthenticationException, IndegoException {
112         return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/", DevicePropertiesResponse.class);
113     }
114
115     private String getAuthorizationUrl() {
116         try {
117             return oAuthClientService.getAuthorizationUrl(BSK_REDIRECT_URI, BSK_SCOPE, null);
118         } catch (OAuthException e) {
119             return "";
120         }
121     }
122
123     private String getAuthorizationHeader() throws IndegoException {
124         final AccessTokenResponse accessTokenResponse;
125         try {
126             accessTokenResponse = oAuthClientService.getAccessTokenResponse();
127         } catch (OAuthException | OAuthResponseException e) {
128             logger.debug("Error fetching access token: {}", e.getMessage(), e);
129             throw new IndegoAuthenticationException(
130                     "Error fetching access token. Invalid authcode? Please generate a new one -> "
131                             + getAuthorizationUrl(),
132                     e);
133         } catch (IOException e) {
134             throw new IndegoException("An unexpected IOException occurred: " + e.getMessage(), e);
135         }
136         if (accessTokenResponse == null || accessTokenResponse.getAccessToken() == null
137                 || accessTokenResponse.getAccessToken().isEmpty()) {
138             throw new IndegoAuthenticationException(
139                     "No access token. Is this thing authorized? -> " + getAuthorizationUrl());
140         }
141         if (accessTokenResponse.getRefreshToken() == null || accessTokenResponse.getRefreshToken().isEmpty()) {
142             throw new IndegoAuthenticationException("No refresh token. Please reauthorize -> " + getAuthorizationUrl());
143         }
144
145         return BEARER + accessTokenResponse.getAccessToken();
146     }
147
148     /**
149      * Sends a GET request to the server and returns the deserialized JSON response.
150      * 
151      * @param path the relative path to which the request should be sent
152      * @param dtoClass the DTO class to which the JSON result should be deserialized
153      * @return the deserialized DTO from the JSON response
154      * @throws IndegoAuthenticationException if request was rejected as unauthorized
155      * @throws IndegoTimeoutException if device cannot be reached (gateway timeout error)
156      * @throws IndegoException if any communication or parsing error occurred
157      */
158     protected <T> T getRequest(String path, Class<? extends T> dtoClass)
159             throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
160         int status = 0;
161         try {
162             Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET)
163                     .header(HttpHeader.AUTHORIZATION, getAuthorizationHeader()).agent(userAgent);
164             if (logger.isTraceEnabled()) {
165                 logger.trace("GET request for {}", BASE_URL + path);
166             }
167             ContentResponse response = sendRequest(request);
168             status = response.getStatus();
169             String jsonResponse = response.getContentAsString();
170             if (!jsonResponse.isEmpty()) {
171                 logger.trace("JSON response: '{}'", jsonResponse);
172             }
173             if (status == HttpStatus.UNAUTHORIZED_401) {
174                 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
175                 throw new IndegoAuthenticationException("Unauthorized");
176             }
177             if (status == HttpStatus.GATEWAY_TIMEOUT_504) {
178                 throw new IndegoTimeoutException("Gateway timeout");
179             }
180             if (!HttpStatus.isSuccess(status)) {
181                 throw new IndegoException("The request failed with error: " + status);
182             }
183             if (jsonResponse.isEmpty()) {
184                 throw new IndegoInvalidResponseException("No content returned", status);
185             }
186
187             @Nullable
188             T result = gson.fromJson(jsonResponse, dtoClass);
189             if (result == null) {
190                 throw new IndegoInvalidResponseException("Parsed response is null", status);
191             }
192             return result;
193         } catch (JsonParseException e) {
194             throw new IndegoInvalidResponseException("Error parsing response", e, status);
195         } catch (InterruptedException e) {
196             Thread.currentThread().interrupt();
197             throw new IndegoException(e);
198         } catch (TimeoutException e) {
199             throw new IndegoException(e);
200         } catch (ExecutionException e) {
201             Throwable cause = e.getCause();
202             if (cause != null && cause instanceof HttpResponseException) {
203                 Response response = ((HttpResponseException) cause).getResponse();
204                 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
205                     /*
206                      * The service may respond with HTTP code 401 without any "WWW-Authenticate"
207                      * header, violating RFC 7235. Jetty will then throw HttpResponseException.
208                      * We need to handle this in order to attempt reauthentication.
209                      */
210                     throw new IndegoAuthenticationException("Unauthorized", e);
211                 }
212             }
213             throw new IndegoException(e);
214         }
215     }
216
217     /**
218      * Sends a GET request to the server and returns the raw response.
219      * 
220      * @param path the relative path to which the request should be sent
221      * @return the raw data from the response
222      * @throws IndegoAuthenticationException if request was rejected as unauthorized
223      * @throws IndegoException if any communication or parsing error occurred
224      */
225     protected RawType getRawRequest(String path) throws IndegoAuthenticationException, IndegoException {
226         int status = 0;
227         try {
228             Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET)
229                     .header(HttpHeader.AUTHORIZATION, getAuthorizationHeader()).agent(userAgent);
230             if (logger.isTraceEnabled()) {
231                 logger.trace("GET request for {}", BASE_URL + path);
232             }
233             ContentResponse response = sendRequest(request);
234             status = response.getStatus();
235             if (status == HttpStatus.UNAUTHORIZED_401) {
236                 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
237                 throw new IndegoAuthenticationException("Context rejected");
238             }
239             if (!HttpStatus.isSuccess(status)) {
240                 throw new IndegoException("The request failed with error: " + status);
241             }
242             byte[] data = response.getContent();
243             if (data == null) {
244                 throw new IndegoInvalidResponseException("No data returned", status);
245             }
246             String contentType = response.getMediaType();
247             if (contentType == null || contentType.isEmpty()) {
248                 throw new IndegoInvalidResponseException("No content-type returned", status);
249             }
250             logger.debug("Media download response: type {}, length {}", contentType, data.length);
251
252             return new RawType(data, contentType);
253         } catch (JsonParseException e) {
254             throw new IndegoInvalidResponseException("Error parsing response", e, status);
255         } catch (InterruptedException e) {
256             Thread.currentThread().interrupt();
257             throw new IndegoException(e);
258         } catch (TimeoutException e) {
259             throw new IndegoException(e);
260         } catch (ExecutionException e) {
261             Throwable cause = e.getCause();
262             if (cause != null && cause instanceof HttpResponseException) {
263                 Response response = ((HttpResponseException) cause).getResponse();
264                 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
265                     /*
266                      * When contextId is not valid, the service will respond with HTTP code 401 without
267                      * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
268                      * HttpResponseException. We need to handle this in order to attempt
269                      * reauthentication.
270                      */
271                     throw new IndegoAuthenticationException("Context rejected", e);
272                 }
273             }
274             throw new IndegoException(e);
275         }
276     }
277
278     /**
279      * Wraps {@link #putPostRequest(HttpMethod, String, Object)} into an authenticated session.
280      * 
281      * @param path the relative path to which the request should be sent
282      * @param requestDto the DTO which should be sent to the server as JSON
283      * @throws IndegoAuthenticationException if request was rejected as unauthorized
284      * @throws IndegoException if any communication or parsing error occurred
285      */
286     protected void putRequestWithAuthentication(String path, Object requestDto)
287             throws IndegoAuthenticationException, IndegoException {
288         putPostRequest(HttpMethod.PUT, path, requestDto);
289     }
290
291     /**
292      * Wraps {@link #putPostRequest(HttpMethod, String, Object)} into an authenticated session.
293      * 
294      * @param path the relative path to which the request should be sent
295      * @throws IndegoAuthenticationException if request was rejected as unauthorized
296      * @throws IndegoException if any communication or parsing error occurred
297      */
298     protected void postRequest(String path) throws IndegoAuthenticationException, IndegoException {
299         putPostRequest(HttpMethod.POST, path, null);
300     }
301
302     /**
303      * Sends a PUT/POST request to the server.
304      * 
305      * @param method the type of request ({@link HttpMethod.PUT} or {@link HttpMethod.POST})
306      * @param path the relative path to which the request should be sent
307      * @param requestDto the DTO which should be sent to the server as JSON
308      * @throws IndegoAuthenticationException if request was rejected as unauthorized
309      * @throws IndegoException if any communication or parsing error occurred
310      */
311     protected void putPostRequest(HttpMethod method, String path, @Nullable Object requestDto)
312             throws IndegoAuthenticationException, IndegoException {
313         try {
314             Request request = httpClient.newRequest(BASE_URL + path).method(method)
315                     .header(HttpHeader.AUTHORIZATION, getAuthorizationHeader())
316                     .header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER).agent(userAgent);
317             if (requestDto != null) {
318                 String payload = gson.toJson(requestDto);
319                 request.content(new StringContentProvider(payload));
320                 if (logger.isTraceEnabled()) {
321                     logger.trace("{} request for {} with payload '{}'", method, BASE_URL + path, payload);
322                 }
323             } else {
324                 logger.trace("{} request for {} with no payload", method, BASE_URL + path);
325             }
326             ContentResponse response = sendRequest(request);
327             String jsonResponse = response.getContentAsString();
328             if (!jsonResponse.isEmpty()) {
329                 logger.trace("JSON response: '{}'", jsonResponse);
330             }
331             int status = response.getStatus();
332             if (status == HttpStatus.UNAUTHORIZED_401) {
333                 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
334                 throw new IndegoAuthenticationException("Context rejected");
335             }
336             if (status == HttpStatus.INTERNAL_SERVER_ERROR_500) {
337                 try {
338                     ErrorResponse result = gson.fromJson(jsonResponse, ErrorResponse.class);
339                     if (result != null) {
340                         throw new IndegoInvalidCommandException("The request failed with HTTP error: " + status,
341                                 result.error);
342                     }
343                 } catch (JsonParseException e) {
344                     // Ignore missing error code, next line will throw.
345                 }
346                 throw new IndegoInvalidCommandException("The request failed with HTTP error: " + status);
347             }
348             if (!HttpStatus.isSuccess(status)) {
349                 throw new IndegoException("The request failed with error: " + status);
350             }
351         } catch (JsonParseException e) {
352             throw new IndegoException("Error serializing request", e);
353         } catch (InterruptedException e) {
354             Thread.currentThread().interrupt();
355             throw new IndegoException(e);
356         } catch (TimeoutException e) {
357             throw new IndegoException(e);
358         } catch (ExecutionException e) {
359             Throwable cause = e.getCause();
360             if (cause != null && cause instanceof HttpResponseException) {
361                 Response response = ((HttpResponseException) cause).getResponse();
362                 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
363                     /*
364                      * When contextId is not valid, the service will respond with HTTP code 401 without
365                      * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
366                      * HttpResponseException. We need to handle this in order to attempt
367                      * reauthentication.
368                      */
369                     throw new IndegoAuthenticationException("Context rejected", e);
370                 }
371             }
372             throw new IndegoException(e);
373         }
374     }
375
376     /**
377      * Send request. This method exists for the purpose of avoiding multiple calls to
378      * the server at the same time.
379      * 
380      * @param request the {@link Request} to send
381      * @return a {@link ContentResponse} for this request
382      * @throws InterruptedException if send thread is interrupted
383      * @throws TimeoutException if send times out
384      * @throws ExecutionException if execution fails
385      */
386     protected synchronized ContentResponse sendRequest(Request request)
387             throws InterruptedException, TimeoutException, ExecutionException {
388         return request.send();
389     }
390 }