001package com.box.sdk;
002
003import static com.box.sdk.BoxSensitiveDataSanitizer.sanitizeHeaders;
004import static com.box.sdk.internal.utils.CollectionUtils.mapToString;
005import static java.lang.String.format;
006
007import com.box.sdk.http.ContentType;
008import com.box.sdk.http.HttpHeaders;
009import com.box.sdk.http.HttpMethod;
010import com.eclipsesource.json.Json;
011import com.eclipsesource.json.JsonObject;
012import com.eclipsesource.json.ParseException;
013import java.io.ByteArrayInputStream;
014import java.io.ByteArrayOutputStream;
015import java.io.IOException;
016import java.io.InputStream;
017import java.io.OutputStream;
018import java.net.HttpURLConnection;
019import java.net.URL;
020import java.util.ArrayList;
021import java.util.List;
022import java.util.Map;
023import java.util.Objects;
024import okhttp3.Headers;
025import okhttp3.MediaType;
026import okhttp3.Request;
027import okhttp3.RequestBody;
028import okhttp3.Response;
029
030/**
031 * Used to make HTTP requests to the Box API.
032 *
033 * <p>All requests to the REST API are sent using this class or one of its subclasses. This class
034 * wraps {@link HttpURLConnection} in order to provide a simpler interface that can automatically
035 * handle various conditions specific to Box's API. Requests will be authenticated using a {@link
036 * BoxAPIConnection} (if one is provided), so it isn't necessary to add authorization headers.
037 * Requests can also be sent more than once, unlike with HttpURLConnection. If an error occurs while
038 * sending a request, it will be automatically retried (with a back off delay) up to the maximum
039 * number of times set in the BoxAPIConnection.
040 *
041 * <p>Specifying a body for a BoxAPIRequest is done differently than it is with HttpURLConnection.
042 * Instead of writing to an OutputStream, the request is provided an {@link InputStream} which will
043 * be read when the {@link #send} method is called. This makes it easy to retry requests since the
044 * stream can automatically reset and reread with each attempt. If the stream cannot be reset, then
045 * a new stream will need to be provided before each call to send. There is also a convenience
046 * method for specifying the body as a String, which simply wraps the String with an InputStream.
047 */
048public class BoxAPIRequest {
049  private static final BoxLogger LOGGER = BoxLogger.defaultLogger();
050  private static final String ERROR_CREATING_REQUEST_BODY = "Error creating request body";
051  private static final int BUFFER_SIZE = 8192;
052  private final BoxAPIConnection api;
053  private final List<RequestHeader> headers;
054  private final String method;
055  private URL url;
056  private BackoffCounter backoffCounter;
057  private int connectTimeout;
058  private int readTimeout;
059  private InputStream body;
060  private long bodyLength;
061  private boolean shouldAuthenticate;
062  private boolean followRedirects = true;
063  private final String mediaType;
064
065  /**
066   * Constructs an authenticated BoxAPIRequest using a provided BoxAPIConnection.
067   *
068   * @param api an API connection for authenticating the request.
069   * @param url the URL of the request.
070   * @param method the HTTP method of the request.
071   */
072  public BoxAPIRequest(BoxAPIConnection api, URL url, String method) {
073    this(api, url, method, ContentType.APPLICATION_FORM_URLENCODED);
074  }
075
076  protected BoxAPIRequest(BoxAPIConnection api, URL url, String method, String mediaType) {
077    this.api = api;
078    this.url = url;
079    this.method = method;
080    this.mediaType = mediaType;
081    this.headers = new ArrayList<>();
082    if (api != null) {
083      Map<String, String> customHeaders = api.getHeaders();
084      if (customHeaders != null) {
085        for (String header : customHeaders.keySet()) {
086          this.addHeader(header, customHeaders.get(header));
087        }
088      }
089      this.headers.add(new RequestHeader("X-Box-UA", api.getBoxUAHeader()));
090    }
091    this.backoffCounter = new BackoffCounter(new Time());
092    this.shouldAuthenticate = true;
093    if (api != null) {
094      this.connectTimeout = api.getConnectTimeout();
095      this.readTimeout = api.getReadTimeout();
096    } else {
097      this.connectTimeout = BoxGlobalSettings.getConnectTimeout();
098      this.readTimeout = BoxGlobalSettings.getReadTimeout();
099    }
100
101    this.addHeader("Accept-Charset", "utf-8");
102  }
103
104  /**
105   * Constructs an authenticated BoxAPIRequest using a provided BoxAPIConnection.
106   *
107   * @param api an API connection for authenticating the request.
108   * @param url the URL of the request.
109   * @param method the HTTP method of the request.
110   */
111  public BoxAPIRequest(BoxAPIConnection api, URL url, HttpMethod method) {
112    this(api, url, method.name());
113  }
114
115  /**
116   * Constructs an request, using URL and HttpMethod.
117   *
118   * @param url the URL of the request.
119   * @param method the HTTP method of the request.
120   */
121  public BoxAPIRequest(URL url, HttpMethod method) {
122    this(null, url, method.name());
123  }
124
125  /**
126   * @param apiException BoxAPIException thrown
127   * @return true if the request is one that should be retried, otherwise false
128   */
129  public static boolean isRequestRetryable(BoxAPIException apiException) {
130    // Only requests that failed to send should be retried
131    return (Objects.equals(apiException.getMessage(), ERROR_CREATING_REQUEST_BODY));
132  }
133
134  /**
135   * @param responseCode HTTP error code of the response
136   * @param apiException BoxAPIException thrown
137   * @return true if the response is one that should be retried, otherwise false
138   */
139  public static boolean isResponseRetryable(int responseCode, BoxAPIException apiException) {
140    if (responseCode >= 500 || responseCode == 429) {
141      return true;
142    }
143    return isClockSkewError(responseCode, apiException);
144  }
145
146  private static boolean isClockSkewError(int responseCode, BoxAPIException apiException) {
147    String response = apiException.getResponse();
148    if (response == null || response.length() == 0) {
149      return false;
150    }
151    String message = apiException.getMessage();
152    String errorCode = "";
153
154    try {
155      JsonObject responseBody = Json.parse(response).asObject();
156      if (responseBody.get("code") != null) {
157        errorCode = responseBody.get("code").toString();
158      } else if (responseBody.get("error") != null) {
159        errorCode = responseBody.get("error").toString();
160      }
161
162      return responseCode == 400 && errorCode.contains("invalid_grant") && message.contains("exp");
163    } catch (ParseException e) {
164      // 400 error which is not a JSON will not trigger a retry
165      throw new BoxAPIException("API returned an error", responseCode, response);
166    }
167  }
168
169  /**
170   * Adds an HTTP header to this request.
171   *
172   * @param key the header key.
173   * @param value the header value.
174   */
175  public void addHeader(String key, String value) {
176    if (key.equals("As-User")) {
177      for (int i = 0; i < this.headers.size(); i++) {
178        if (this.headers.get(i).getKey().equals("As-User")) {
179          this.headers.remove(i);
180        }
181      }
182    }
183    if (key.equals("X-Box-UA")) {
184      throw new IllegalArgumentException("Altering the X-Box-UA header is not permitted");
185    }
186    this.headers.add(new RequestHeader(key, value));
187  }
188
189  /**
190   * Gets the connect timeout for the request.
191   *
192   * @return the request connection timeout.
193   */
194  public int getConnectTimeout() {
195    return this.connectTimeout;
196  }
197
198  /**
199   * Sets a Connect timeout for this request in milliseconds.
200   *
201   * @param timeout the timeout in milliseconds.
202   */
203  public void setConnectTimeout(int timeout) {
204    this.connectTimeout = timeout;
205  }
206
207  /**
208   * Gets the read timeout for the request.
209   *
210   * @return the request's read timeout.
211   */
212  public int getReadTimeout() {
213    return this.readTimeout;
214  }
215
216  /**
217   * Sets a read timeout for this request in milliseconds.
218   *
219   * @param timeout the timeout in milliseconds.
220   */
221  public void setReadTimeout(int timeout) {
222    this.readTimeout = timeout;
223  }
224
225  /**
226   * Sets whether or not to follow redirects (i.e. Location header)
227   *
228   * @param followRedirects true to follow, false to not follow
229   */
230  public void setFollowRedirects(boolean followRedirects) {
231    this.followRedirects = followRedirects;
232  }
233
234  /**
235   * Gets the stream containing contents of this request's body.
236   *
237   * <p>Note that any bytes that read from the returned stream won't be sent unless the stream is
238   * reset back to its initial position.
239   *
240   * @return an InputStream containing the contents of this request's body.
241   */
242  public InputStream getBody() {
243    return this.body;
244  }
245
246  /**
247   * Sets the request body to the contents of an InputStream.
248   *
249   * <p>The stream must support the {@link InputStream#reset} method if auto-retry is used or if the
250   * request needs to be resent. Otherwise, the body must be manually set before each call to {@link
251   * #send}.
252   *
253   * @param stream an InputStream containing the contents of the body.
254   */
255  public void setBody(InputStream stream) {
256    this.body = stream;
257  }
258
259  /**
260   * Sets the request body to the contents of a String.
261   *
262   * <p>If the contents of the body are large, then it may be more efficient to use an {@link
263   * InputStream} instead of a String. Using a String requires that the entire body be in memory
264   * before sending the request.
265   *
266   * @param body a String containing the contents of the body.
267   */
268  public void setBody(String body) {
269    byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
270    this.bodyLength = bytes.length;
271    this.body = new ByteArrayInputStream(bytes);
272  }
273
274  /**
275   * Sets the request body to the contents of an InputStream.
276   *
277   * <p>Providing the length of the InputStream allows for the progress of the request to be
278   * monitored when calling {@link #send(ProgressListener)}.
279   *
280   * <p>See {@link #setBody(InputStream)} for more information on setting the body of the request.
281   *
282   * @param stream an InputStream containing the contents of the body.
283   * @param length the expected length of the stream.
284   */
285  public void setBody(InputStream stream, long length) {
286    this.bodyLength = length;
287    this.body = stream;
288  }
289
290  /**
291   * Gets the URL from the request.
292   *
293   * @return a URL containing the URL of the request.
294   */
295  public URL getUrl() {
296    return this.url;
297  }
298
299  /** Sets the URL to the request. */
300  public void setUrl(URL url) {
301    this.url = url;
302  }
303
304  /**
305   * Gets the http method from the request.
306   *
307   * @return http method
308   */
309  public String getMethod() {
310    return this.method;
311  }
312
313  /**
314   * Get headers as list of RequestHeader objects.
315   *
316   * @return headers as list of RequestHeader objects
317   */
318  List<RequestHeader> getHeaders() {
319    return this.headers;
320  }
321
322  /**
323   * Sends this request and returns a BoxAPIResponse containing the server's response.
324   *
325   * <p>The type of the returned BoxAPIResponse will be based on the content type returned by the
326   * server, allowing it to be cast to a more specific type. For example, if it's known that the API
327   * call will return a JSON response, then it can be cast to a {@link BoxJSONResponse} like so:
328   *
329   * <pre>BoxJSONResponse response = (BoxJSONResponse) request.sendWithoutRetry();</pre>
330   *
331   * @return a {@link BoxAPIResponse} containing the server's response.
332   * @throws BoxAPIException if the server returns an error code or if a network error occurs.
333   */
334  public BoxAPIResponse sendWithoutRetry() {
335    return this.trySend(null);
336  }
337
338  /**
339   * Sends this request and returns a BoxAPIResponse containing the server's response.
340   *
341   * <p>The type of the returned BoxAPIResponse will be based on the content type returned by the
342   * server, allowing it to be cast to a more specific type. For example, if it's known that the API
343   * call will return a JSON response, then it can be cast to a {@link BoxJSONResponse} like so:
344   *
345   * <pre>BoxJSONResponse response = (BoxJSONResponse) request.send();</pre>
346   *
347   * <p>If the server returns an error code or if a network error occurs, then the request will be
348   * automatically retried. If the maximum number of retries is reached and an error still occurs,
349   * then a {@link BoxAPIException} will be thrown.
350   *
351   * <p>See {@link #send} for more information on sending requests.
352   *
353   * @return a {@link BoxAPIResponse} containing the server's response.
354   * @throws BoxAPIException if the server returns an error code or if a network error occurs.
355   */
356  public BoxAPIResponse send() {
357    return this.send(null);
358  }
359
360  /**
361   * Sends this request while monitoring its progress and returns a BoxAPIResponse containing the
362   * server's response.
363   *
364   * <p>The type of the returned BoxAPIResponse will be based on the content type returned by the
365   * server, allowing it to be cast to a more specific type. For example, if it's known that the API
366   * call will return a JSON response, then it can be cast to a {@link BoxJSONResponse} like so:
367   *
368   * <p>If the server returns an error code or if a network error occurs, then the request will be
369   * automatically retried. If the maximum number of retries is reached and an error still occurs,
370   * then a {@link BoxAPIException} will be thrown.
371   *
372   * <p>A ProgressListener is generally only useful when the size of the request is known
373   * beforehand. If the size is unknown, then the ProgressListener will be updated for each byte
374   * sent, but the total number of bytes will be reported as 0.
375   *
376   * <p>See {@link #send} for more information on sending requests.
377   *
378   * @param listener a listener for monitoring the progress of the request.
379   * @return a {@link BoxAPIResponse} containing the server's response.
380   * @throws BoxAPIException if the server returns an error code or if a network error occurs.
381   */
382  public BoxAPIResponse send(ProgressListener listener) {
383    if (this.api == null) {
384      this.backoffCounter.reset(BoxGlobalSettings.getMaxRetryAttempts() + 1);
385    } else {
386      this.backoffCounter.reset(this.api.getMaxRetryAttempts() + 1);
387    }
388
389    while (this.backoffCounter.getAttemptsRemaining() > 0) {
390      try {
391        return this.trySend(listener);
392      } catch (BoxAPIException apiException) {
393        if (!this.backoffCounter.decrement()
394            || (!isRequestRetryable(apiException)
395                && !isResponseRetryable(apiException.getResponseCode(), apiException))) {
396          throw apiException;
397        }
398
399        LOGGER.warn(
400            format(
401                "Retrying request due to transient error status=%d body=%s headers=%s",
402                apiException.getResponseCode(),
403                apiException.getResponse(),
404                mapToString(apiException.getHeaders())));
405
406        try {
407          this.resetBody();
408        } catch (IOException ioException) {
409          throw apiException;
410        }
411
412        try {
413          List<String> retryAfterHeader = apiException.getHeaders().get("Retry-After");
414          if (retryAfterHeader == null) {
415            this.backoffCounter.waitBackoff();
416          } else {
417            int retryAfterDelay = Integer.parseInt(retryAfterHeader.get(0)) * 1000;
418            this.backoffCounter.waitBackoff(retryAfterDelay);
419          }
420        } catch (InterruptedException interruptedException) {
421          Thread.currentThread().interrupt();
422          throw apiException;
423        }
424      }
425    }
426
427    throw new RuntimeException();
428  }
429
430  /**
431   * Disables adding authentication header to request. Useful when you want to add your own
432   * authentication method. Default value is `true` and SKD will add authenticaton header to
433   * request.
434   *
435   * @param shouldAuthenticate use `false` to disable authentication.
436   */
437  public void shouldAuthenticate(boolean shouldAuthenticate) {
438    this.shouldAuthenticate = shouldAuthenticate;
439  }
440
441  /**
442   * Sends a request to upload a file part and returns a BoxFileUploadSessionPart containing
443   * information about the upload part. This method is separate from send() because it has custom
444   * retry logic.
445   *
446   * <p>If the server returns an error code or if a network error occurs, then the request will be
447   * automatically retried. If the maximum number of retries is reached and an error still occurs,
448   * then a {@link BoxAPIException} will be thrown.
449   *
450   * @param session The BoxFileUploadSession uploading the part
451   * @param offset Offset of the part being uploaded
452   * @return A {@link BoxFileUploadSessionPart} part that has been uploaded.
453   * @throws BoxAPIException if the server returns an error code or if a network error occurs.
454   */
455  BoxFileUploadSessionPart sendForUploadPart(BoxFileUploadSession session, long offset) {
456    if (this.api == null) {
457      this.backoffCounter.reset(BoxGlobalSettings.getMaxRetryAttempts() + 1);
458    } else {
459      this.backoffCounter.reset(this.api.getMaxRetryAttempts() + 1);
460    }
461
462    while (this.backoffCounter.getAttemptsRemaining() > 0) {
463      try (BoxJSONResponse response = (BoxJSONResponse) this.trySend(null)) {
464        // upload sends binary data but response is JSON
465        JsonObject jsonObject = Json.parse(response.getJSON()).asObject();
466        return new BoxFileUploadSessionPart((JsonObject) jsonObject.get("part"));
467      } catch (BoxAPIException apiException) {
468        if (!this.backoffCounter.decrement()
469            || (!isRequestRetryable(apiException)
470                && !isResponseRetryable(apiException.getResponseCode(), apiException))) {
471          throw apiException;
472        }
473        if (apiException.getResponseCode() == 500) {
474          try {
475            Iterable<BoxFileUploadSessionPart> parts = session.listParts();
476            for (BoxFileUploadSessionPart part : parts) {
477              if (part.getOffset() == offset) {
478                return part;
479              }
480            }
481          } catch (BoxAPIException e) {
482            // ignoring exception as we are retrying
483          }
484        }
485        LOGGER.warn(
486            format(
487                "Retrying request due to transient error status=%d body=%s",
488                apiException.getResponseCode(), apiException.getResponse()));
489
490        try {
491          this.resetBody();
492        } catch (IOException ioException) {
493          throw apiException;
494        }
495
496        try {
497          this.backoffCounter.waitBackoff();
498        } catch (InterruptedException interruptedException) {
499          Thread.currentThread().interrupt();
500          throw apiException;
501        }
502      }
503    }
504
505    throw new RuntimeException();
506  }
507
508  /**
509   * Returns a String containing the URL, HTTP method, headers and body of this request.
510   *
511   * @return a String containing information about this request.
512   */
513  @Override
514  public String toString() {
515    return toStringWithHeaders(null);
516  }
517
518  private String toStringWithHeaders(Headers headers) {
519    String lineSeparator = System.getProperty("line.separator");
520    StringBuilder builder = new StringBuilder();
521    builder.append("Request");
522    builder.append(lineSeparator);
523    builder.append(this.method);
524    builder.append(' ');
525    builder.append(this.url.toString());
526    builder.append(lineSeparator);
527    if (headers != null) {
528      builder.append("Headers:").append(lineSeparator);
529      sanitizeHeaders(headers)
530          .forEach(
531              h ->
532                  builder.append(format("%s: [%s]%s", h.getFirst(), h.getSecond(), lineSeparator)));
533    }
534
535    String bodyString = this.bodyToString();
536    if (bodyString != null) {
537      builder.append(lineSeparator);
538      builder.append(bodyString);
539    }
540
541    return builder.toString().trim();
542  }
543
544  /**
545   * Returns a String representation of this request's body used in {@link #toString}. This method
546   * returns null by default.
547   *
548   * <p>A subclass may want override this method if the body can be converted to a String for
549   * logging or debugging purposes.
550   *
551   * @return a String representation of this request's body.
552   */
553  protected String bodyToString() {
554    return null;
555  }
556
557  private void writeWithBuffer(OutputStream output, ProgressListener listener) {
558    try {
559      OutputStream finalOutput = output;
560      if (listener != null) {
561        finalOutput = new ProgressOutputStream(output, listener, this.bodyLength);
562      }
563      byte[] buffer = new byte[BUFFER_SIZE];
564      int b = this.body.read(buffer);
565      while (b != -1) {
566        finalOutput.write(buffer, 0, b);
567        b = this.body.read(buffer);
568      }
569    } catch (IOException e) {
570      throw new RuntimeException("Error writting body", e);
571    }
572  }
573
574  /**
575   * Resets the InputStream containing this request's body.
576   *
577   * <p>This method will be called before each attempt to resend the request, giving subclasses an
578   * opportunity to reset any streams that need to be read when sending the body.
579   *
580   * @throws IOException if the stream cannot be reset.
581   */
582  protected void resetBody() throws IOException {
583    if (this.body != null) {
584      this.body.reset();
585    }
586  }
587
588  void setBackoffCounter(BackoffCounter counter) {
589    this.backoffCounter = counter;
590  }
591
592  private BoxAPIResponse trySend(ProgressListener listener) {
593    if (this.api != null) {
594      RequestInterceptor interceptor = this.api.getRequestInterceptor();
595      if (interceptor != null) {
596        BoxAPIResponse response = interceptor.onRequest(this);
597        if (response != null) {
598          return response;
599        }
600      }
601    }
602    long start = System.currentTimeMillis();
603    Request request = composeRequest(listener);
604    Response response;
605    this.logRequest(request.headers());
606    if (this.followRedirects) {
607      response = api.execute(request);
608    } else {
609      response = api.executeWithoutRedirect(request);
610    }
611    logDebug(
612        format("[trySend] connection.connect() took %dms%n", (System.currentTimeMillis() - start)));
613
614    BoxAPIResponse result = BoxAPIResponse.toBoxResponse(response);
615    long getResponseStart = System.currentTimeMillis();
616    logDebug(
617        format(
618            "[trySend] Get Response (read network) took %dms%n",
619            System.currentTimeMillis() - getResponseStart));
620    return result;
621  }
622
623  private Request composeRequest(ProgressListener listener) {
624    Request.Builder requestBuilder = new Request.Builder().url(getUrl());
625    if (this.shouldAuthenticate) {
626      requestBuilder.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + this.api.lockAccessToken());
627    }
628    try {
629      requestBuilder.addHeader("User-Agent", this.api.getUserAgent());
630      requestBuilder.addHeader("X-Box-UA", this.api.getBoxUAHeader());
631      headers.forEach(
632          h -> {
633            requestBuilder.removeHeader(h.getKey());
634            requestBuilder.addHeader(h.getKey(), h.getValue());
635          });
636
637      if (this.api instanceof SharedLinkAPIConnection) {
638        SharedLinkAPIConnection sharedItemAPI = (SharedLinkAPIConnection) this.api;
639        String boxAPIValue =
640            BoxSharedLink.getSharedLinkHeaderValue(
641                sharedItemAPI.getSharedLink(), sharedItemAPI.getSharedLinkPassword());
642        requestBuilder.addHeader("BoxApi", boxAPIValue);
643      }
644
645      writeMethodWithBody(requestBuilder, listener);
646      return requestBuilder.build();
647    } finally {
648      if (this.shouldAuthenticate) {
649        this.api.unlockAccessToken();
650      }
651    }
652  }
653
654  protected void writeMethodWithBody(Request.Builder requestBuilder, ProgressListener listener) {
655    ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream();
656    if (body != null) {
657      long writeStart = System.currentTimeMillis();
658      writeWithBuffer(bodyBytes, listener);
659      logDebug(
660          format("[trySend] Body write took %dms%n", (System.currentTimeMillis() - writeStart)));
661    }
662    if (method.equals("GET")) {
663      requestBuilder.get();
664    }
665    if (method.equals("DELETE")) {
666      requestBuilder.delete();
667    }
668    if (method.equals("OPTIONS")) {
669      if (body == null) {
670        requestBuilder.method("OPTIONS", null);
671      } else {
672        requestBuilder.method("OPTIONS", RequestBody.create(bodyBytes.toByteArray(), mediaType()));
673      }
674    }
675    if (method.equals("POST")) {
676      requestBuilder.post(RequestBody.create(bodyBytes.toByteArray(), mediaType()));
677    }
678    if (method.equals("PUT")) {
679      requestBuilder.put(RequestBody.create(bodyBytes.toByteArray(), mediaType()));
680    }
681  }
682
683  private void logDebug(String message) {
684    if (LOGGER.isDebugEnabled()) {
685      LOGGER.debug(message);
686    }
687  }
688
689  private void logRequest(Headers headers) {
690    logDebug(headers != null ? this.toStringWithHeaders(headers) : this.toString());
691  }
692
693  /** Class for mapping a request header and value. */
694  public static final class RequestHeader {
695    private final String key;
696    private final String value;
697
698    /**
699     * Construct a request header from header key and value.
700     *
701     * @param key header name
702     * @param value header value
703     */
704    public RequestHeader(String key, String value) {
705      this.key = key;
706      this.value = value;
707    }
708
709    /**
710     * Get header key.
711     *
712     * @return http header name
713     */
714    public String getKey() {
715      return this.key;
716    }
717
718    /**
719     * Get header value.
720     *
721     * @return http header value
722     */
723    public String getValue() {
724      return this.value;
725    }
726  }
727
728  protected MediaType mediaType() {
729    return MediaType.parse(mediaType);
730  }
731}