2 * Copyright (c) 2010-2023 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.unifi.internal.api;
15 import java.io.ByteArrayOutputStream;
16 import java.io.IOException;
17 import java.io.InputStream;
18 import java.net.ConnectException;
19 import java.net.UnknownHostException;
20 import java.nio.charset.StandardCharsets;
21 import java.util.HashMap;
23 import java.util.Map.Entry;
24 import java.util.concurrent.ExecutionException;
25 import java.util.concurrent.TimeUnit;
26 import java.util.concurrent.TimeoutException;
28 import javax.net.ssl.SSLException;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.client.HttpClient;
33 import org.eclipse.jetty.client.HttpResponseException;
34 import org.eclipse.jetty.client.api.ContentResponse;
35 import org.eclipse.jetty.client.api.Request;
36 import org.eclipse.jetty.client.api.Response;
37 import org.eclipse.jetty.client.util.InputStreamResponseListener;
38 import org.eclipse.jetty.client.util.StringContentProvider;
39 import org.eclipse.jetty.http.HttpMethod;
40 import org.eclipse.jetty.http.HttpScheme;
41 import org.eclipse.jetty.http.HttpStatus;
42 import org.eclipse.jetty.http.HttpURI;
43 import org.eclipse.jetty.http.MimeTypes;
44 import org.slf4j.Logger;
45 import org.slf4j.LoggerFactory;
47 import com.google.gson.Gson;
48 import com.google.gson.GsonBuilder;
49 import com.google.gson.JsonObject;
50 import com.google.gson.JsonParseException;
51 import com.google.gson.JsonParser;
54 * The {@link UniFiControllerRequest} encapsulates a request sent by the {@link UniFiController}.
56 * @author Matthew Bowman - Initial contribution
58 * @param <T> The response type expected as a result of the request's execution
61 class UniFiControllerRequest<T> {
63 private static final String CONTROLLER_PARSE_ERROR = "@text/error.controller.parse_error";
65 private static final String CONTENT_TYPE_APPLICATION_JSON_UTF_8 = MimeTypes.Type.APPLICATION_JSON_UTF_8.asString();
67 private static final long TIMEOUT_SECONDS = 5;
69 private static final String PROPERTY_DATA = "data";
71 private final Logger logger = LoggerFactory.getLogger(UniFiControllerRequest.class);
73 private final Gson gson;
75 private final HttpClient httpClient;
77 private final String host;
79 private final int port;
81 private final boolean unifios;
83 private final HttpMethod method;
85 private String path = "/";
87 private String csrfToken;
89 private final Map<String, String> queryParameters = new HashMap<>();
91 private final Map<String, Object> bodyParameters = new HashMap<>();
93 private final Class<T> resultType;
97 public UniFiControllerRequest(final Class<T> resultType, final Gson gson, final HttpClient httpClient,
98 final HttpMethod method, final String host, final int port, final String csrfToken, final boolean unifios) {
99 this.resultType = resultType;
101 this.httpClient = httpClient;
102 this.method = method;
105 this.csrfToken = csrfToken;
106 this.unifios = unifios;
109 public void setAPIPath(final String relativePath) {
111 this.path = "/proxy/network" + relativePath;
113 this.path = relativePath;
117 public void setPath(final String path) {
121 public void setBodyParameter(final String key, final Object value) {
122 this.bodyParameters.put(key, value);
125 public void setQueryParameter(final String key, final Object value) {
126 this.queryParameters.put(key, String.valueOf(value));
129 public @Nullable T execute() throws UniFiException {
131 final String json = getContent();
132 // mgb: only try and unmarshall non-void result types
133 if (!Void.class.equals(resultType)) {
135 final JsonObject jsonObject = JsonParser.parseString(json).getAsJsonObject();
137 if (jsonObject.has(PROPERTY_DATA) && jsonObject.get(PROPERTY_DATA).isJsonArray()) {
138 result = (T) gson.fromJson(jsonObject.getAsJsonArray(PROPERTY_DATA), resultType);
140 } catch (final JsonParseException e) {
142 "Could not parse content retrieved from the server. Is the configuration pointing to the right server/port?, {}",
144 if (logger.isTraceEnabled()) {
145 prettyPrintJson(json);
147 throw new UniFiCommunicationException(CONTROLLER_PARSE_ERROR);
155 private String getContent() throws UniFiException {
157 final InputStreamResponseListener listener = new InputStreamResponseListener();
158 final Response response = getContentResponse(listener);
159 final int status = response.getStatus();
161 case HttpStatus.OK_200:
162 content = responseToString(listener);
163 if (logger.isTraceEnabled()) {
164 logger.trace("<< {} {} \n{}", status, HttpStatus.getMessage(status), prettyPrintJson(content));
167 final String csrfToken = response.getHeaders().get("X-CSRF-Token");
168 if (csrfToken != null && !csrfToken.isEmpty()) {
169 this.csrfToken = csrfToken;
172 case HttpStatus.BAD_REQUEST_400:
173 logger.info("UniFi returned a status 400: {}", prettyPrintJson(responseToString(listener)));
174 throw new UniFiInvalidCredentialsException("Invalid Credentials");
175 case HttpStatus.UNAUTHORIZED_401:
176 throw new UniFiExpiredSessionException("Expired Credentials");
177 case HttpStatus.FORBIDDEN_403:
178 throw new UniFiNotAuthorizedException("Unauthorized Access");
180 logger.info("UniFi returned a status code {}: {}", status, prettyPrintJson(responseToString(listener)));
181 throw new UniFiException("Unknown HTTP status code " + status + " returned by the controller");
186 private Response getContentResponse(final InputStreamResponseListener listener) throws UniFiException {
187 final Request request = newRequest();
188 logger.trace(">> {} {}", request.getMethod(), request.getURI());
191 request.send(listener);
192 response = listener.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
193 } catch (TimeoutException | InterruptedException e) {
194 throw new UniFiCommunicationException(e);
195 } catch (final ExecutionException e) {
196 // mgb: unwrap the cause and try to cleanly handle it
197 final Throwable cause = e.getCause();
198 if (cause instanceof TimeoutException) {
199 throw new UniFiCommunicationException(e);
200 } else if (cause instanceof UnknownHostException) {
202 throw new UniFiInvalidHostException(cause);
203 } else if (cause instanceof ConnectException) {
205 throw new UniFiCommunicationException(cause);
206 } else if (cause instanceof SSLException) {
207 // cannot establish ssl connection
208 throw new UniFiSSLException(cause);
209 } else if (cause instanceof HttpResponseException httpResponseException
210 && httpResponseException.getResponse() instanceof ContentResponse) {
211 // the UniFi controller violates the HTTP protocol
212 // - it returns 401 UNAUTHORIZED without the WWW-Authenticate response header
213 // - this causes an ExecutionException to be thrown
214 // - we unwrap the response from the exception for proper handling of the 401 status code
215 response = httpResponseException.getResponse();
218 throw new UniFiException(cause);
224 private static String responseToString(final InputStreamResponseListener listener) throws UniFiException {
225 final ByteArrayOutputStream responseContent = new ByteArrayOutputStream();
226 try (InputStream input = listener.getInputStream()) {
227 input.transferTo(responseContent);
228 } catch (final IOException e) {
229 throw new UniFiException(e);
231 return new String(responseContent.toByteArray(), StandardCharsets.UTF_8);
234 public String getCsrfToken() {
238 private Request newRequest() {
239 final HttpURI uri = new HttpURI(HttpScheme.HTTPS.asString(), host, port, path);
240 final Request request = httpClient.newRequest(uri.toString()).timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
242 for (final Entry<String, String> entry : queryParameters.entrySet()) {
243 request.param(entry.getKey(), entry.getValue());
245 if (!bodyParameters.isEmpty()) {
246 final String jsonBody = gson.toJson(bodyParameters);
248 logger.debug("Body parameters for request '{}': {}", request.getPath(), jsonBody);
250 new StringContentProvider(CONTENT_TYPE_APPLICATION_JSON_UTF_8, jsonBody, StandardCharsets.UTF_8));
253 if (!csrfToken.isEmpty()) {
254 request.header("x-csrf-token", this.csrfToken);
260 private String prettyPrintJson(final String content) {
262 final JsonObject json = JsonParser.parseString(content).getAsJsonObject();
263 final Gson prettyGson = new GsonBuilder().setPrettyPrinting().create();
265 return prettyGson.toJson(json);
266 } catch (final RuntimeException e) {
267 logger.debug("RuntimeException pretty printing JSON. Returning the raw content.", e);
268 // If could not parse the string as JSON, just return the string