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