001package com.box.sdk;
002
003import static com.box.sdk.StandardCharsets.UTF_8;
004import static com.box.sdk.http.ContentType.APPLICATION_JSON;
005import static java.lang.String.format;
006
007import com.eclipsesource.json.Json;
008import com.eclipsesource.json.ParseException;
009import java.io.ByteArrayInputStream;
010import java.io.Closeable;
011import java.io.IOException;
012import java.io.InputStream;
013import java.io.InputStreamReader;
014import java.net.HttpURLConnection;
015import java.util.List;
016import java.util.Map;
017import java.util.Objects;
018import java.util.Optional;
019import java.util.TreeMap;
020import okhttp3.MediaType;
021import okhttp3.Response;
022import okhttp3.ResponseBody;
023
024/**
025 * Used to read HTTP responses from the Box API.
026 *
027 * <p>All responses from the REST API are read using this class or one of its subclasses. This class
028 * wraps {@link HttpURLConnection} in order to provide a simpler interface that can automatically
029 * handle various conditions specific to Box's API. When a response is contructed, it will throw a
030 * {@link BoxAPIException} if the response from the API was an error. Therefore every BoxAPIResponse
031 * instance is guaranteed to represent a successful response.
032 *
033 * <p>This class usually isn't instantiated directly, but is instead returned after calling {@link
034 * BoxAPIRequest#send}.
035 */
036public class BoxAPIResponse implements Closeable {
037  private static final int BUFFER_SIZE = 8192;
038  private static final BoxLogger LOGGER = BoxLogger.defaultLogger();
039  private final Map<String, List<String>> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
040  private final long contentLength;
041  private final String contentType;
042  private final String requestMethod;
043  private final String requestUrl;
044  private int responseCode;
045  private String bodyString;
046
047  /**
048   * The raw InputStream is the stream returned directly from HttpURLConnection.getInputStream(). We
049   * need to keep track of this stream in case we need to access it after wrapping it inside another
050   * stream.
051   */
052  private InputStream rawInputStream;
053
054  /**
055   * The regular InputStream is the stream that will be returned by getBody(). This stream might be
056   * a GZIPInputStream or a ProgressInputStream (or both) that wrap the raw InputStream.
057   */
058  private InputStream inputStream;
059
060  /** Constructs an empty BoxAPIResponse without an associated HttpURLConnection. */
061  public BoxAPIResponse() {
062    this.contentLength = 0;
063    this.contentType = null;
064    this.requestMethod = null;
065    this.requestUrl = null;
066  }
067
068  /**
069   * Constructs a BoxAPIResponse with a http response code and response headers.
070   *
071   * @param responseCode http response code
072   * @param headers map of headers
073   */
074  public BoxAPIResponse(
075      int responseCode,
076      String requestMethod,
077      String requestUrl,
078      Map<String, List<String>> headers) {
079    this(responseCode, requestMethod, requestUrl, headers, null, null, 0);
080  }
081
082  public BoxAPIResponse(
083      int responseCode,
084      String requestMethod,
085      String requestUrl,
086      Map<String, List<String>> headers,
087      String bodyString,
088      String contentType) {
089    this(responseCode, requestMethod, requestUrl, headers, null, contentType, 0, bodyString);
090  }
091
092  public BoxAPIResponse(
093      int code,
094      String requestMethod,
095      String requestUrl,
096      Map<String, List<String>> headers,
097      InputStream body,
098      String contentType,
099      long contentLength) {
100    this(code, requestMethod, requestUrl, headers, body, contentType, contentLength, null);
101  }
102
103  public BoxAPIResponse(
104      int code,
105      String requestMethod,
106      String requestUrl,
107      Map<String, List<String>> headers,
108      InputStream body,
109      String contentType,
110      long contentLength,
111      String bodyString) {
112    this.responseCode = code;
113    this.requestMethod = requestMethod;
114    this.requestUrl = requestUrl;
115    if (headers != null) {
116      this.headers.putAll(headers);
117    }
118    this.rawInputStream = body;
119    this.contentType = contentType;
120    this.contentLength = contentLength;
121    this.bodyString = bodyString;
122    if (body != null) {
123      storeBodyResponse(body);
124    }
125    if (isSuccess(responseCode)) {
126      this.logResponse();
127    } else {
128      this.logErrorResponse(this.responseCode);
129      throw new BoxAPIResponseException(
130          "The API returned an error code", responseCode, null, headers);
131    }
132  }
133
134  private static boolean isSuccess(int responseCode) {
135    return responseCode >= 200 && responseCode < 400;
136  }
137
138  static BoxAPIResponse toBoxResponse(Response response) {
139    if (!response.isSuccessful() && !response.isRedirect()) {
140      throw new BoxAPIResponseException(
141          "The API returned an error code",
142          response.code(),
143          Optional.ofNullable(response.body())
144              .map(
145                  body -> {
146                    try {
147                      return body.string();
148                    } catch (IOException e) {
149                      throw new RuntimeException(e);
150                    }
151                  })
152              .orElse("Body was null"),
153          response.headers().toMultimap());
154    }
155    ResponseBody responseBody = response.body();
156    if (responseBody.contentType() == null) {
157      try {
158        return emptyContentResponse(response);
159      } finally {
160        responseBody.close();
161      }
162    }
163    if (responseBody != null && responseBody.contentType() != null) {
164      if (responseBody.contentType().toString().contains(APPLICATION_JSON)) {
165        if (responseBody.contentLength() == 0) {
166          return emptyContentResponse(response);
167        }
168        String bodyAsString = "";
169        try {
170          bodyAsString = responseBody.string();
171          return new BoxJSONResponse(
172              response.code(),
173              response.request().method(),
174              response.request().url().toString(),
175              response.headers().toMultimap(),
176              Json.parse(bodyAsString).asObject());
177        } catch (ParseException e) {
178          throw new BoxAPIException(format("Error parsing JSON:\n%s", bodyAsString), e);
179        } catch (IOException e) {
180          throw new RuntimeException("Error getting response to string", e);
181        } finally {
182          responseBody.close();
183        }
184      }
185    }
186    return new BoxAPIResponse(
187        response.code(),
188        response.request().method(),
189        response.request().url().toString(),
190        response.headers().toMultimap(),
191        responseBody.byteStream(),
192        Optional.ofNullable(responseBody.contentType()).map(MediaType::toString).orElse(null),
193        responseBody.contentLength());
194  }
195
196  private static BoxAPIResponse emptyContentResponse(Response response) {
197    return new BoxAPIResponse(
198        response.code(),
199        response.request().method(),
200        response.request().url().toString(),
201        response.headers().toMultimap());
202  }
203
204  private void storeBodyResponse(InputStream body) {
205    try {
206      if (contentType != null
207          && body != null
208          && contentType.contains(APPLICATION_JSON)
209          && body.available() > 0) {
210        InputStreamReader reader = new InputStreamReader(this.getBody(), UTF_8);
211        StringBuilder builder = new StringBuilder();
212        char[] buffer = new char[BUFFER_SIZE];
213
214        int read = reader.read(buffer, 0, BUFFER_SIZE);
215        while (read != -1) {
216          builder.append(buffer, 0, read);
217          read = reader.read(buffer, 0, BUFFER_SIZE);
218        }
219        reader.close();
220        this.disconnect();
221        bodyString = builder.toString();
222        rawInputStream = new ByteArrayInputStream(bodyString.getBytes(UTF_8));
223      }
224    } catch (IOException e) {
225      throw new RuntimeException("Cannot read body stream", e);
226    }
227  }
228
229  /**
230   * Gets the response code returned by the API.
231   *
232   * @return the response code returned by the API.
233   */
234  public int getResponseCode() {
235    return this.responseCode;
236  }
237
238  /**
239   * Gets the length of this response's body as indicated by the "Content-Length" header.
240   *
241   * @return the length of the response's body.
242   */
243  public long getContentLength() {
244    return this.contentLength;
245  }
246
247  /**
248   * Gets the value of the given header field.
249   *
250   * @param fieldName name of the header field.
251   * @return value of the header.
252   */
253  public String getHeaderField(String fieldName) {
254    return Optional.ofNullable(this.headers.get(fieldName)).map((l) -> l.get(0)).orElse("");
255  }
256
257  /**
258   * Gets an InputStream for reading this response's body.
259   *
260   * @return an InputStream for reading the response's body.
261   */
262  public InputStream getBody() {
263    return this.getBody(null);
264  }
265
266  /**
267   * Gets an InputStream for reading this response's body which will report its read progress to a
268   * ProgressListener.
269   *
270   * @param listener a listener for monitoring the read progress of the body.
271   * @return an InputStream for reading the response's body.
272   */
273  public InputStream getBody(ProgressListener listener) {
274    if (this.inputStream == null) {
275      if (listener == null) {
276        this.inputStream = this.rawInputStream;
277      } else {
278        this.inputStream =
279            new ProgressInputStream(this.rawInputStream, listener, this.getContentLength());
280      }
281    }
282    return this.inputStream;
283  }
284
285  /**
286   * Disconnects this response from the server and frees up any network resources. The body of this
287   * response can no longer be read after it has been disconnected.
288   */
289  public void disconnect() {
290    this.close();
291  }
292
293  /** @return A Map containg headers on this Box API Response. */
294  public Map<String, List<String>> getHeaders() {
295    return this.headers;
296  }
297
298  @Override
299  public String toString() {
300    String lineSeparator = System.getProperty("line.separator");
301    StringBuilder builder = new StringBuilder();
302    builder
303        .append("Response")
304        .append(lineSeparator)
305        .append(this.requestMethod)
306        .append(' ')
307        .append(this.requestUrl)
308        .append(' ')
309        .append(this.responseCode)
310        .append(lineSeparator)
311        .append(contentType != null ? "Content-Type: " + contentType + lineSeparator : "")
312        .append(headers.isEmpty() ? "" : "Headers:" + lineSeparator);
313    headers.entrySet().stream()
314        .filter(Objects::nonNull)
315        .forEach(
316            e ->
317                builder.append(
318                    format(
319                        "%s: [%s]%s",
320                        e.getKey().toLowerCase(java.util.Locale.ROOT),
321                        e.getValue(),
322                        lineSeparator)));
323
324    String bodyString = this.bodyToString();
325    if (bodyString != null && !bodyString.equals("")) {
326      String sanitizedBodyString =
327          contentType.equals(APPLICATION_JSON)
328              ? BoxSensitiveDataSanitizer.sanitizeJsonBody(Json.parse(bodyString).asObject())
329                  .toString()
330              : bodyString;
331      builder.append("Body:").append(lineSeparator).append(sanitizedBodyString);
332    }
333
334    return builder.toString().trim();
335  }
336
337  @Override
338  public void close() {
339    try {
340      if (this.inputStream == null && this.rawInputStream != null) {
341        this.rawInputStream.close();
342      }
343      if (this.inputStream != null) {
344        this.inputStream.close();
345      }
346    } catch (IOException e) {
347      throw new BoxAPIException(
348          "Couldn't finish closing the connection to the Box API due to a network error or "
349              + "because the stream was already closed.",
350          e);
351    }
352  }
353
354  /**
355   * Returns a string representation of this response's body. This method is used when logging this
356   * response's body. By default, it returns an empty string (to avoid accidentally logging binary
357   * data) unless the response contained an error message or content type is application/json.
358   *
359   * @return a string representation of this response's body.
360   */
361  protected String bodyToString() {
362    return this.bodyString;
363  }
364
365  private void logResponse() {
366    if (LOGGER.isDebugEnabled()) {
367      LOGGER.debug(this.toString());
368    }
369  }
370
371  private void logErrorResponse(int responseCode) {
372    if (responseCode < 500 && LOGGER.isWarnEnabled()) {
373      LOGGER.warn(this.toString());
374    }
375    if (responseCode >= 500 && LOGGER.isErrorEnabled()) {
376      LOGGER.error(this.toString());
377    }
378  }
379
380  protected String getRequestMethod() {
381    return requestMethod;
382  }
383
384  protected String getRequestUrl() {
385    return requestUrl;
386  }
387}