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