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