001package com.box.sdk;
002
003import static java.lang.String.format;
004
005import com.box.sdk.http.HttpHeaders;
006import com.box.sdk.http.HttpMethod;
007import com.eclipsesource.json.Json;
008import com.eclipsesource.json.JsonObject;
009import java.io.ByteArrayInputStream;
010import java.io.IOException;
011import java.io.InputStream;
012import java.io.OutputStream;
013import java.net.HttpURLConnection;
014import java.net.MalformedURLException;
015import java.net.ProtocolException;
016import java.net.URL;
017import java.security.KeyManagementException;
018import java.security.NoSuchAlgorithmException;
019import java.util.ArrayList;
020import java.util.List;
021import java.util.Map;
022import java.util.Objects;
023import javax.net.ssl.HttpsURLConnection;
024import javax.net.ssl.SSLContext;
025import javax.net.ssl.SSLParameters;
026import javax.net.ssl.SSLSocketFactory;
027
028
029/**
030 * Used to make HTTP requests to the Box API.
031 *
032 * <p>All requests to the REST API are sent using this class or one of its subclasses. This class wraps {@link
033 * HttpURLConnection} in order to provide a simpler interface that can automatically handle various conditions specific
034 * to Box's API. Requests will be authenticated using a {@link BoxAPIConnection} (if one is provided), so it isn't
035 * necessary to add authorization headers. Requests can also be sent more than once, unlike with HttpURLConnection. If
036 * an error occurs while sending a request, it will be automatically retried (with a back off delay) up to the maximum
037 * number of times set in the BoxAPIConnection.</p>
038 *
039 * <p>Specifying a body for a BoxAPIRequest is done differently than it is with HttpURLConnection. Instead of writing to
040 * an OutputStream, the request is provided an {@link InputStream} which will be read when the {@link #send} method is
041 * called. This makes it easy to retry requests since the stream can automatically reset and reread with each attempt.
042 * If the stream cannot be reset, then a new stream will need to be provided before each call to send. There is also a
043 * convenience method for specifying the body as a String, which simply wraps the String with an InputStream.</p>
044 */
045public class BoxAPIRequest {
046    private static final BoxLogger LOGGER = BoxLogger.defaultLogger();
047    private static final int MAX_REDIRECTS = 3;
048    private static final String ERROR_CREATING_REQUEST_BODY = "Error creating request body";
049    private static final int BUFFER_SIZE = 8192;
050    private static SSLSocketFactory sslSocketFactory;
051
052    static {
053        // Setup the SSL context manually to force newer TLS version on legacy Java environments
054        // This is necessary because Java 7 uses TLSv1.0 by default, but the Box API will need
055        // to deprecate this protocol in the future.  To prevent clients from breaking, we must
056        // ensure that they are using TLSv1.1 or greater!
057        SSLContext sc = null;
058        try {
059            sc = SSLContext.getDefault();
060            SSLParameters params = sc.getDefaultSSLParameters();
061            boolean supportsNewTLS = false;
062            for (String protocol : params.getProtocols()) {
063                if (protocol.compareTo("TLSv1") > 0) {
064                    supportsNewTLS = true;
065                    break;
066                }
067            }
068            if (!supportsNewTLS) {
069                // Try to upgrade to a higher TLS version
070                sc = null;
071                sc = SSLContext.getInstance("TLSv1.1");
072                sc.init(null, null, new java.security.SecureRandom());
073                sc = SSLContext.getInstance("TLSv1.2");
074                sc.init(null, null, new java.security.SecureRandom());
075            }
076        } catch (NoSuchAlgorithmException ex) {
077            if (sc == null) {
078                LOGGER.error("Unable to set up SSL context for HTTPS! "
079                    + "This may result in the inability  to connect to the Box API.");
080            }
081            if (sc != null && sc.getProtocol().equals("TLSv1")) {
082                // Could not find a good version of TLS
083                LOGGER.error("Using deprecated TLSv1 protocol, which will be deprecated by the Box API! "
084                    + "Upgrade to a newer version of Java as soon as possible.");
085            }
086        } catch (KeyManagementException ex) {
087            LOGGER.error(
088                "Exception when initializing SSL Context!  This may result in the inabilty to connect to the Box API"
089            );
090            sc = null;
091        }
092
093        if (sc != null) {
094            sslSocketFactory = sc.getSocketFactory();
095        }
096
097    }
098
099    private final BoxAPIConnection api;
100    private final List<RequestHeader> headers;
101    private final String method;
102    private URL url;
103    private BackoffCounter backoffCounter;
104    private int connectTimeout;
105    private int readTimeout;
106    private InputStream body;
107    private long bodyLength;
108    private Map<String, List<String>> requestProperties;
109    private int numRedirects;
110    private boolean followRedirects = true;
111    private boolean shouldAuthenticate;
112
113    /**
114     * Constructs an unauthenticated BoxAPIRequest.
115     *
116     * @param url    the URL of the request.
117     * @param method the HTTP method of the request.
118     */
119    public BoxAPIRequest(URL url, String method) {
120        this(null, url, method);
121    }
122
123    /**
124     * Constructs an authenticated BoxAPIRequest using a provided BoxAPIConnection.
125     *
126     * @param api    an API connection for authenticating the request.
127     * @param url    the URL of the request.
128     * @param method the HTTP method of the request.
129     */
130    public BoxAPIRequest(BoxAPIConnection api, URL url, String method) {
131        this.api = api;
132        this.url = url;
133        this.method = method;
134        this.headers = new ArrayList<>();
135        if (api != null) {
136            Map<String, String> customHeaders = api.getHeaders();
137            if (customHeaders != null) {
138                for (String header : customHeaders.keySet()) {
139                    this.addHeader(header, customHeaders.get(header));
140                }
141            }
142            this.headers.add(new RequestHeader("X-Box-UA", api.getBoxUAHeader()));
143        }
144        this.backoffCounter = new BackoffCounter(new Time());
145        this.shouldAuthenticate = true;
146        if (api != null) {
147            this.connectTimeout = api.getConnectTimeout();
148            this.readTimeout = api.getReadTimeout();
149        } else {
150            this.connectTimeout = BoxGlobalSettings.getConnectTimeout();
151            this.readTimeout = BoxGlobalSettings.getReadTimeout();
152        }
153
154        this.addHeader("Accept-Encoding", "gzip");
155        this.addHeader("Accept-Charset", "utf-8");
156
157    }
158
159    /**
160     * Constructs an authenticated BoxAPIRequest using a provided BoxAPIConnection.
161     *
162     * @param api    an API connection for authenticating the request.
163     * @param url    the URL of the request.
164     * @param method the HTTP method of the request.
165     */
166    public BoxAPIRequest(BoxAPIConnection api, URL url, HttpMethod method) {
167        this(api, url, method.name());
168    }
169
170    /**
171     * Constructs an request, using URL and HttpMethod.
172     *
173     * @param url    the URL of the request.
174     * @param method the HTTP method of the request.
175     */
176    public BoxAPIRequest(URL url, HttpMethod method) {
177        this(url, method.name());
178    }
179
180    /**
181     * @param apiException BoxAPIException thrown
182     * @return true if the request is one that should be retried, otherwise false
183     */
184    public static boolean isRequestRetryable(BoxAPIException apiException) {
185        // Only requests that failed to send should be retried
186        return (Objects.equals(apiException.getMessage(), ERROR_CREATING_REQUEST_BODY));
187    }
188
189    /**
190     * @param responseCode HTTP error code of the response
191     * @param apiException BoxAPIException thrown
192     * @return true if the response is one that should be retried, otherwise false
193     */
194    public static boolean isResponseRetryable(int responseCode, BoxAPIException apiException) {
195        String response = apiException.getResponse();
196        String message = apiException.getMessage();
197        String errorCode = "";
198
199        try {
200            JsonObject responseBody = Json.parse(response).asObject();
201            if (responseBody.get("code") != null) {
202                errorCode = responseBody.get("code").toString();
203            } else if (responseBody.get("error") != null) {
204                errorCode = responseBody.get("error").toString();
205            }
206        } catch (Exception e) {
207        }
208
209        boolean isClockSkewError = responseCode == 400
210            && errorCode.contains("invalid_grant")
211            && message.contains("exp");
212
213        return (isClockSkewError
214            || responseCode >= 500
215            || responseCode == 429);
216    }
217
218    private static boolean isResponseRedirect(int responseCode) {
219        return (responseCode == 301 || responseCode == 302);
220    }
221
222    /**
223     * Adds an HTTP header to this request.
224     *
225     * @param key   the header key.
226     * @param value the header value.
227     */
228    public void addHeader(String key, String value) {
229        if (key.equals("As-User")) {
230            for (int i = 0; i < this.headers.size(); i++) {
231                if (this.headers.get(i).getKey().equals("As-User")) {
232                    this.headers.remove(i);
233                }
234            }
235        }
236        if (key.equals("X-Box-UA")) {
237            throw new IllegalArgumentException("Altering the X-Box-UA header is not permitted");
238        }
239        this.headers.add(new RequestHeader(key, value));
240    }
241
242    /**
243     * Gets the connect timeout for the request.
244     *
245     * @return the request connection timeout.
246     */
247    public int getConnectTimeout() {
248        return this.connectTimeout;
249    }
250
251    /**
252     * Sets a Connect timeout for this request in milliseconds.
253     *
254     * @param timeout the timeout in milliseconds.
255     */
256    public void setConnectTimeout(int timeout) {
257        this.connectTimeout = timeout;
258    }
259
260    /**
261     * Gets the read timeout for the request.
262     *
263     * @return the request's read timeout.
264     */
265    public int getReadTimeout() {
266        return this.readTimeout;
267    }
268
269    /**
270     * Sets a read timeout for this request in milliseconds.
271     *
272     * @param timeout the timeout in milliseconds.
273     */
274    public void setReadTimeout(int timeout) {
275        this.readTimeout = timeout;
276    }
277
278    /**
279     * Sets whether or not to follow redirects (i.e. Location header)
280     *
281     * @param followRedirects true to follow, false to not follow
282     */
283    public void setFollowRedirects(boolean followRedirects) {
284        this.followRedirects = followRedirects;
285    }
286
287    /**
288     * Gets the stream containing contents of this request's body.
289     *
290     * <p>Note that any bytes that read from the returned stream won't be sent unless the stream is reset back to its
291     * initial position.</p>
292     *
293     * @return an InputStream containing the contents of this request's body.
294     */
295    public InputStream getBody() {
296        return this.body;
297    }
298
299    /**
300     * Sets the request body to the contents of an InputStream.
301     *
302     * <p>The stream must support the {@link InputStream#reset} method if auto-retry is used or if the request needs to
303     * be resent. Otherwise, the body must be manually set before each call to {@link #send}.</p>
304     *
305     * @param stream an InputStream containing the contents of the body.
306     */
307    public void setBody(InputStream stream) {
308        this.body = stream;
309    }
310
311    /**
312     * Sets the request body to the contents of a String.
313     *
314     * <p>If the contents of the body are large, then it may be more efficient to use an {@link InputStream} instead of
315     * a String. Using a String requires that the entire body be in memory before sending the request.</p>
316     *
317     * @param body a String containing the contents of the body.
318     */
319    public void setBody(String body) {
320        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
321        this.bodyLength = bytes.length;
322        this.body = new ByteArrayInputStream(bytes);
323    }
324
325    /**
326     * Sets the request body to the contents of an InputStream.
327     *
328     * <p>Providing the length of the InputStream allows for the progress of the request to be monitored when calling
329     * {@link #send(ProgressListener)}.</p>
330     *
331     * <p> See {@link #setBody(InputStream)} for more information on setting the body of the request.</p>
332     *
333     * @param stream an InputStream containing the contents of the body.
334     * @param length the expected length of the stream.
335     */
336    public void setBody(InputStream stream, long length) {
337        this.bodyLength = length;
338        this.body = stream;
339    }
340
341    /**
342     * Gets the URL from the request.
343     *
344     * @return a URL containing the URL of the request.
345     */
346    public URL getUrl() {
347        return this.url;
348    }
349
350    /**
351     * Gets the http method from the request.
352     *
353     * @return http method
354     */
355    public String getMethod() {
356        return this.method;
357    }
358
359    /**
360     * Get headers as list of RequestHeader objects.
361     *
362     * @return headers as list of RequestHeader objects
363     */
364    protected List<RequestHeader> getHeaders() {
365        return this.headers;
366    }
367
368    /**
369     * Sends this request and returns a BoxAPIResponse containing the server's response.
370     *
371     * <p>The type of the returned BoxAPIResponse will be based on the content type returned by the server, allowing it
372     * to be cast to a more specific type. For example, if it's known that the API call will return a JSON response,
373     * then it can be cast to a {@link BoxJSONResponse} like so:</p>
374     *
375     * <pre>BoxJSONResponse response = (BoxJSONResponse) request.sendWithoutRetry();</pre>
376     *
377     * @return a {@link BoxAPIResponse} containing the server's response.
378     * @throws BoxAPIException if the server returns an error code or if a network error occurs.
379     */
380    public BoxAPIResponse sendWithoutRetry() {
381        return this.trySend(null);
382    }
383
384    /**
385     * Sends this request and returns a BoxAPIResponse containing the server's response.
386     *
387     * <p>The type of the returned BoxAPIResponse will be based on the content type returned by the server, allowing it
388     * to be cast to a more specific type. For example, if it's known that the API call will return a JSON response,
389     * then it can be cast to a {@link BoxJSONResponse} like so:</p>
390     *
391     * <pre>BoxJSONResponse response = (BoxJSONResponse) request.send();</pre>
392     *
393     * <p>If the server returns an error code or if a network error occurs, then the request will be automatically
394     * retried. If the maximum number of retries is reached and an error still occurs, then a {@link BoxAPIException}
395     * will be thrown.</p>
396     *
397     * <p> See {@link #send} for more information on sending requests.</p>
398     *
399     * @return a {@link BoxAPIResponse} containing the server's response.
400     * @throws BoxAPIException if the server returns an error code or if a network error occurs.
401     */
402    public BoxAPIResponse send() {
403        return this.send(null);
404    }
405
406    /**
407     * Sends this request while monitoring its progress and returns a BoxAPIResponse containing the server's response.
408     *
409     * <p>The type of the returned BoxAPIResponse will be based on the content type returned by the server, allowing it
410     * to be cast to a more specific type. For example, if it's known that the API call will return a JSON response,
411     * then it can be cast to a {@link BoxJSONResponse} like so:</p>
412     *
413     * <p>If the server returns an error code or if a network error occurs, then the request will be automatically
414     * retried. If the maximum number of retries is reached and an error still occurs, then a {@link BoxAPIException}
415     * will be thrown.</p>
416     *
417     * <p>A ProgressListener is generally only useful when the size of the request is known beforehand. If the size is
418     * unknown, then the ProgressListener will be updated for each byte sent, but the total number of bytes will be
419     * reported as 0.</p>
420     *
421     * <p> See {@link #send} for more information on sending requests.</p>
422     *
423     * @param listener a listener for monitoring the progress of the request.
424     * @return a {@link BoxAPIResponse} containing the server's response.
425     * @throws BoxAPIException if the server returns an error code or if a network error occurs.
426     */
427    public BoxAPIResponse send(ProgressListener listener) {
428        if (this.api == null) {
429            this.backoffCounter.reset(BoxGlobalSettings.getMaxRetryAttempts() + 1);
430        } else {
431            this.backoffCounter.reset(this.api.getMaxRetryAttempts() + 1);
432        }
433
434        while (this.backoffCounter.getAttemptsRemaining() > 0) {
435            try {
436                return this.trySend(listener);
437            } catch (BoxAPIException apiException) {
438                if (!this.backoffCounter.decrement()
439                    || (!isRequestRetryable(apiException)
440                    && !isResponseRetryable(apiException.getResponseCode(), apiException))) {
441                    throw apiException;
442                }
443
444                LOGGER.warn(
445                    format("Retrying request due to transient error status=%d body=%s",
446                        apiException.getResponseCode(),
447                        apiException.getResponse())
448                );
449
450                try {
451                    this.resetBody();
452                } catch (IOException ioException) {
453                    throw apiException;
454                }
455
456                try {
457                    List<String> retryAfterHeader = apiException.getHeaders().get("Retry-After");
458                    if (retryAfterHeader == null) {
459                        this.backoffCounter.waitBackoff();
460                    } else {
461                        int retryAfterDelay = Integer.parseInt(retryAfterHeader.get(0)) * 1000;
462                        this.backoffCounter.waitBackoff(retryAfterDelay);
463                    }
464                } catch (InterruptedException interruptedException) {
465                    Thread.currentThread().interrupt();
466                    throw apiException;
467                }
468            }
469        }
470
471        throw new RuntimeException();
472    }
473
474    /**
475     * Sends a request to upload a file part and returns a BoxFileUploadSessionPart containing information
476     * about the upload part. This method is separate from send() because it has custom retry logic.
477     *
478     * <p>If the server returns an error code or if a network error occurs, then the request will be automatically
479     * retried. If the maximum number of retries is reached and an error still occurs, then a {@link BoxAPIException}
480     * will be thrown.</p>
481     *
482     * @param session The BoxFileUploadSession uploading the part
483     * @param offset  Offset of the part being uploaded
484     * @return A {@link BoxFileUploadSessionPart} part that has been uploaded.
485     * @throws BoxAPIException if the server returns an error code or if a network error occurs.
486     */
487    BoxFileUploadSessionPart sendForUploadPart(BoxFileUploadSession session, long offset) {
488        if (this.api == null) {
489            this.backoffCounter.reset(BoxGlobalSettings.getMaxRetryAttempts() + 1);
490        } else {
491            this.backoffCounter.reset(this.api.getMaxRetryAttempts() + 1);
492        }
493
494        while (this.backoffCounter.getAttemptsRemaining() > 0) {
495            try {
496                BoxJSONResponse response = (BoxJSONResponse) this.trySend(null);
497                JsonObject jsonObject = Json.parse(response.getJSON()).asObject();
498                return new BoxFileUploadSessionPart((JsonObject) jsonObject.get("part"));
499            } catch (BoxAPIException apiException) {
500                if (!this.backoffCounter.decrement()
501                    || (!isRequestRetryable(apiException)
502                    && !isResponseRetryable(apiException.getResponseCode(), apiException))) {
503                    throw apiException;
504                }
505                if (apiException.getResponseCode() == 500) {
506                    try {
507                        Iterable<BoxFileUploadSessionPart> parts = session.listParts();
508                        for (BoxFileUploadSessionPart part : parts) {
509                            if (part.getOffset() == offset) {
510                                return part;
511                            }
512                        }
513                    } catch (BoxAPIException e) {
514                    }
515                }
516                LOGGER.warn(format(
517                    "Retrying request due to transient error status=%d body=%s",
518                    apiException.getResponseCode(),
519                    apiException.getResponse()
520                ));
521
522                try {
523                    this.resetBody();
524                } catch (IOException ioException) {
525                    throw apiException;
526                }
527
528                try {
529                    this.backoffCounter.waitBackoff();
530                } catch (InterruptedException interruptedException) {
531                    Thread.currentThread().interrupt();
532                    throw apiException;
533                }
534            }
535        }
536
537        throw new RuntimeException();
538    }
539
540    /**
541     * Returns a String containing the URL, HTTP method, headers and body of this request.
542     *
543     * @return a String containing information about this request.
544     */
545    @Override
546    public String toString() {
547        String lineSeparator = System.getProperty("line.separator");
548        StringBuilder builder = new StringBuilder();
549        builder.append("Request");
550        builder.append(lineSeparator);
551        builder.append(this.method);
552        builder.append(' ');
553        builder.append(this.url.toString());
554        builder.append(lineSeparator);
555
556        if (this.requestProperties != null) {
557
558            for (Map.Entry<String, List<String>> entry : this.requestProperties.entrySet()) {
559                List<String> nonEmptyValues = new ArrayList<>();
560                for (String value : entry.getValue()) {
561                    if (value != null && value.trim().length() != 0) {
562                        nonEmptyValues.add(value);
563                    }
564                }
565
566                if (nonEmptyValues.size() == 0) {
567                    continue;
568                }
569
570                builder.append(entry.getKey());
571                builder.append(": ");
572                for (String value : nonEmptyValues) {
573                    builder.append(value);
574                    builder.append(", ");
575                }
576
577                builder.delete(builder.length() - 2, builder.length());
578                builder.append(lineSeparator);
579            }
580        }
581
582        String bodyString = this.bodyToString();
583        if (bodyString != null) {
584            builder.append(lineSeparator);
585            builder.append(bodyString);
586        }
587
588        return builder.toString().trim();
589    }
590
591    /**
592     * Returns a String representation of this request's body used in {@link #toString}. This method returns
593     * null by default.
594     *
595     * <p>A subclass may want override this method if the body can be converted to a String for logging or debugging
596     * purposes.</p>
597     *
598     * @return a String representation of this request's body.
599     */
600    protected String bodyToString() {
601        return null;
602    }
603
604    /**
605     * Writes the body of this request to an HttpURLConnection.
606     *
607     * <p>Subclasses overriding this method must remember to close the connection's OutputStream after writing.</p>
608     *
609     * @param connection the connection to which the body should be written.
610     * @param listener   an optional listener for monitoring the write progress.
611     * @throws BoxAPIException if an error occurs while writing to the connection.
612     */
613    protected void writeBody(HttpURLConnection connection, ProgressListener listener) {
614        if (this.body == null) {
615            return;
616        }
617
618        connection.setDoOutput(true);
619        try {
620            OutputStream output = connection.getOutputStream();
621            if (listener != null) {
622                output = new ProgressOutputStream(output, listener, this.bodyLength);
623            }
624            writeWithBuffer(output);
625            output.close();
626        } catch (IOException e) {
627            throw new BoxAPIException(ERROR_CREATING_REQUEST_BODY, e);
628        }
629    }
630
631    private void writeWithBuffer(OutputStream output) throws IOException {
632        byte[] buffer = new byte[BUFFER_SIZE];
633        int b = this.body.read(buffer);
634        while (b != -1) {
635            output.write(buffer, 0, b);
636            b = this.body.read(buffer);
637        }
638    }
639
640    /**
641     * Resets the InputStream containing this request's body.
642     *
643     * <p>This method will be called before each attempt to resend the request, giving subclasses an opportunity to
644     * reset any streams that need to be read when sending the body.</p>
645     *
646     * @throws IOException if the stream cannot be reset.
647     */
648    protected void resetBody() throws IOException {
649        if (this.body != null) {
650            this.body.reset();
651        }
652    }
653
654    void setBackoffCounter(BackoffCounter counter) {
655        this.backoffCounter = counter;
656    }
657
658    private BoxAPIResponse trySend(ProgressListener listener) {
659        if (this.api != null) {
660            RequestInterceptor interceptor = this.api.getRequestInterceptor();
661            if (interceptor != null) {
662                BoxAPIResponse response = interceptor.onRequest(this);
663                if (response != null) {
664                    return response;
665                }
666            }
667        }
668
669        HttpURLConnection connection = this.createConnection();
670
671        if (connection instanceof HttpsURLConnection) {
672            HttpsURLConnection httpsConnection = (HttpsURLConnection) connection;
673
674            if (sslSocketFactory != null) {
675                httpsConnection.setSSLSocketFactory(sslSocketFactory);
676            }
677        }
678
679        if (this.bodyLength > 0) {
680            connection.setFixedLengthStreamingMode((int) this.bodyLength);
681            connection.setDoOutput(true);
682        }
683
684        if (this.api != null) {
685            if (this.shouldAuthenticate) {
686                connection.addRequestProperty(HttpHeaders.AUTHORIZATION, "Bearer " + this.api.lockAccessToken());
687            }
688            connection.setRequestProperty("User-Agent", this.api.getUserAgent());
689            if (this.api.getProxy() != null) {
690                if (this.api.getProxyUsername() != null && this.api.getProxyPassword() != null) {
691                    String usernameAndPassword = this.api.getProxyUsername() + ":" + this.api.getProxyPassword();
692                    String encoded = Base64.encode(usernameAndPassword.getBytes());
693                    connection.addRequestProperty("Proxy-Authorization", "Basic " + encoded);
694                }
695            }
696
697            if (this.api instanceof SharedLinkAPIConnection) {
698                SharedLinkAPIConnection sharedItemAPI = (SharedLinkAPIConnection) this.api;
699                String sharedLink = sharedItemAPI.getSharedLink();
700                String boxAPIValue = "shared_link=" + sharedLink;
701                String sharedLinkPassword = sharedItemAPI.getSharedLinkPassword();
702                if (sharedLinkPassword != null) {
703                    boxAPIValue += "&shared_link_password=" + sharedLinkPassword;
704                }
705                connection.addRequestProperty("BoxApi", boxAPIValue);
706            }
707        }
708
709        this.requestProperties = connection.getRequestProperties();
710
711        int responseCode;
712        try {
713            long writeStart = System.currentTimeMillis();
714            this.writeBody(connection, listener);
715            logDebug(format("[trySend] Body write took %dms%n", (System.currentTimeMillis() - writeStart)));
716            // Ensure that we're connected in case writeBody() didn't write anything.
717            try {
718                long start = System.currentTimeMillis();
719                connection.connect();
720                logDebug(format("[trySend] connection.connect() took %dms%n", (System.currentTimeMillis() - start)));
721            } catch (IOException e) {
722                throw new BoxAPIException("Couldn't connect to the Box API due to a network error.", e);
723            }
724
725            this.logRequest();
726
727            // We need to manually handle redirects by creating a new HttpURLConnection so that connection pooling
728            // happens correctly. There seems to be a bug in Oracle's Java implementation where automatically handled
729            // redirects will not keep the connection alive.
730            try {
731                long getResponseStart = System.currentTimeMillis();
732                responseCode = connection.getResponseCode();
733                logDebug(format(
734                    "[trySend] Get Response (read network) took %dms%n", System.currentTimeMillis() - getResponseStart
735                ));
736            } catch (IOException e) {
737                throw new BoxAPIException("Couldn't connect to the Box API due to a network error.", e);
738            }
739        } finally {
740            if (this.api != null && this.shouldAuthenticate) {
741                this.api.unlockAccessToken();
742            }
743        }
744
745        if (isResponseRedirect(responseCode)) {
746            return this.handleRedirect(connection, listener);
747        }
748
749        String contentType = connection.getContentType();
750        BoxAPIResponse response;
751        if (contentType == null) {
752            response = new BoxAPIResponse(connection);
753        } else if (contentType.contains("application/json")) {
754            response = new BoxJSONResponse(connection);
755        } else {
756            response = new BoxAPIResponse(connection);
757        }
758
759        return response;
760    }
761
762    private BoxAPIResponse handleRedirect(HttpURLConnection connection, ProgressListener listener) {
763        if (this.numRedirects >= MAX_REDIRECTS) {
764            throw new BoxAPIException("The Box API responded with too many redirects.");
765        }
766        this.numRedirects++;
767
768        // Even though the redirect response won't have a body, we need to read the InputStream so that Java will put
769        // the connection back in the connection pool.
770        try {
771            InputStream stream = connection.getInputStream();
772            byte[] buffer = new byte[8192];
773            int n = stream.read(buffer);
774            while (n != -1) {
775                n = stream.read(buffer);
776            }
777            stream.close();
778        } catch (IOException e) {
779            throw new BoxAPIException("Couldn't connect to the Box API due to a network error.", e);
780        }
781
782        String redirect = connection.getHeaderField("Location");
783        try {
784            this.url = new URL(redirect);
785        } catch (MalformedURLException e) {
786            throw new BoxAPIException("The Box API responded with an invalid redirect.", e);
787        }
788
789        if (this.followRedirects) {
790            return this.trySend(listener);
791        } else {
792            BoxRedirectResponse redirectResponse = new BoxRedirectResponse();
793            redirectResponse.setRedirectURL(this.url);
794            return redirectResponse;
795        }
796    }
797
798    private void logDebug(String message) {
799        if (LOGGER.isDebugEnabled()) {
800            LOGGER.debug(message);
801        }
802    }
803
804    private void logRequest() {
805        logDebug(this.toString());
806    }
807
808    private HttpURLConnection createConnection() {
809        HttpURLConnection connection;
810
811        try {
812            if (this.api == null || this.api.getProxy() == null) {
813                connection = (HttpURLConnection) this.url.openConnection();
814            } else {
815                connection = (HttpURLConnection) this.url.openConnection(this.api.getProxy());
816            }
817        } catch (IOException e) {
818            throw new BoxAPIException("Couldn't connect to the Box API due to a network error.", e);
819        }
820
821        try {
822            connection.setRequestMethod(this.method);
823        } catch (ProtocolException e) {
824            throw new BoxAPIException("Couldn't connect to the Box API because the request's method was invalid.", e);
825        }
826
827        connection.setConnectTimeout(this.connectTimeout);
828        connection.setReadTimeout(this.readTimeout);
829
830        // Don't allow HttpURLConnection to automatically redirect because it messes up the connection pool. See the
831        // trySend(ProgressListener) method for how we handle redirects.
832        connection.setInstanceFollowRedirects(false);
833
834        for (RequestHeader header : this.headers) {
835            connection.addRequestProperty(header.getKey(), header.getValue());
836        }
837
838        return connection;
839    }
840
841    void shouldAuthenticate(boolean shouldAuthenticate) {
842        this.shouldAuthenticate = shouldAuthenticate;
843    }
844
845    /**
846     * Class for mapping a request header and value.
847     */
848    public static final class RequestHeader {
849        private final String key;
850        private final String value;
851
852        /**
853         * Construct a request header from header key and value.
854         *
855         * @param key   header name
856         * @param value header value
857         */
858        public RequestHeader(String key, String value) {
859            this.key = key;
860            this.value = value;
861        }
862
863        /**
864         * Get header key.
865         *
866         * @return http header name
867         */
868        public String getKey() {
869            return this.key;
870        }
871
872        /**
873         * Get header value.
874         *
875         * @return http header value
876         */
877        public String getValue() {
878            return this.value;
879        }
880    }
881}