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