001package com.box.sdk;
002
003import java.io.ByteArrayInputStream;
004import java.io.IOException;
005import java.io.InputStream;
006import java.io.OutputStream;
007import java.net.HttpURLConnection;
008import java.net.MalformedURLException;
009import java.net.ProtocolException;
010import java.net.URL;
011import java.util.ArrayList;
012import java.util.List;
013import java.util.Map;
014import java.util.logging.Level;
015import java.util.logging.Logger;
016
017import com.box.sdk.http.HttpHeaders;
018import com.box.sdk.http.HttpMethod;
019
020/**
021 * Used to make HTTP requests to the Box API.
022 *
023 * <p>All requests to the REST API are sent using this class or one of its subclasses. This class wraps {@link
024 * HttpURLConnection} in order to provide a simpler interface that can automatically handle various conditions specific
025 * to Box's API. Requests will be authenticated using a {@link BoxAPIConnection} (if one is provided), so it isn't
026 * necessary to add authorization headers. Requests can also be sent more than once, unlike with HttpURLConnection. If
027 * an error occurs while sending a request, it will be automatically retried (with a back off delay) up to the maximum
028 * number of times set in the BoxAPIConnection.</p>
029 *
030 * <p>Specifying a body for a BoxAPIRequest is done differently than it is with HttpURLConnection. Instead of writing to
031 * an OutputStream, the request is provided an {@link InputStream} which will be read when the {@link #send} method is
032 * called. This makes it easy to retry requests since the stream can automatically reset and reread with each attempt.
033 * If the stream cannot be reset, then a new stream will need to be provided before each call to send. There is also a
034 * convenience method for specifying the body as a String, which simply wraps the String with an InputStream.</p>
035 */
036public class BoxAPIRequest {
037    private static final Logger LOGGER = Logger.getLogger(BoxAPIRequest.class.getName());
038    private static final int BUFFER_SIZE = 8192;
039    private static final int MAX_REDIRECTS = 3;
040
041    private final BoxAPIConnection api;
042    private final List<RequestHeader> headers;
043    private final String method;
044
045    private URL url;
046    private BackoffCounter backoffCounter;
047    private int connectTimeout;
048    private int readTimeout;
049    private InputStream body;
050    private long bodyLength;
051    private Map<String, List<String>> requestProperties;
052    private int numRedirects;
053    private boolean followRedirects = true;
054    private boolean shouldAuthenticate;
055
056    /**
057     * Constructs an unauthenticated BoxAPIRequest.
058     * @param  url    the URL of the request.
059     * @param  method the HTTP method of the request.
060     */
061    public BoxAPIRequest(URL url, String method) {
062        this(null, url, method);
063    }
064
065    /**
066     * Constructs an authenticated BoxAPIRequest using a provided BoxAPIConnection.
067     * @param  api    an API connection for authenticating the request.
068     * @param  url    the URL of the request.
069     * @param  method the HTTP method of the request.
070     */
071    public BoxAPIRequest(BoxAPIConnection api, URL url, String method) {
072        this.api = api;
073        this.url = url;
074        this.method = method;
075        this.headers = new ArrayList<RequestHeader>();
076        if (api != null) {
077            this.headers.add(new RequestHeader("X-Box-UA", api.getBoxUAHeader()));
078        }
079        this.backoffCounter = new BackoffCounter(new Time());
080        this.shouldAuthenticate = true;
081        this.connectTimeout = BoxGlobalSettings.getConnectTimeout();
082        this.readTimeout = BoxGlobalSettings.getReadTimeout();
083
084        this.addHeader("Accept-Encoding", "gzip");
085        this.addHeader("Accept-Charset", "utf-8");
086    }
087
088    /**
089     * Constructs an authenticated BoxAPIRequest using a provided BoxAPIConnection.
090     * @param  api    an API connection for authenticating the request.
091     * @param  url the URL of the request.
092     * @param  method the HTTP method of the request.
093     */
094    public BoxAPIRequest(BoxAPIConnection api, URL url, HttpMethod method) {
095        this(api, url, method.name());
096    }
097
098    /**
099     * Constructs an request, using URL and HttpMethod.
100     * @param  url the URL of the request.
101     * @param  method the HTTP method of the request.
102     */
103    public BoxAPIRequest(URL url, HttpMethod method) {
104        this(url, method.name());
105    }
106
107    /**
108     * Adds an HTTP header to this request.
109     * @param key   the header key.
110     * @param value the header value.
111     */
112    public void addHeader(String key, String value) {
113        if ("As-User".equals(key)) {
114            int index = this.headers.indexOf("As-User");
115            if (index > -1) {
116                this.headers.remove(index);
117            }
118        }
119        if (key.equals("X-Box-UA")) {
120            throw new IllegalArgumentException("Altering the X-Box-UA header is not permitted");
121        }
122        this.headers.add(new RequestHeader(key, value));
123    }
124
125    /**
126     * Sets a Connect timeout for this request in milliseconds.
127     * @param timeout the timeout in milliseconds.
128     */
129    public void setConnectTimeout(int timeout) {
130        this.connectTimeout = timeout;
131    }
132
133    /**
134     * Sets a read timeout for this request in milliseconds.
135     * @param timeout the timeout in milliseconds.
136     */
137    public void setReadTimeout(int timeout) {
138        this.readTimeout = timeout;
139    }
140
141  /**
142     * Sets whether or not to follow redirects (i.e. Location header)
143     * @param followRedirects true to follow, false to not follow
144     */
145    public void setFollowRedirects(boolean followRedirects) {
146        this.followRedirects = followRedirects;
147    }
148
149    /**
150     * Gets the stream containing contents of this request's body.
151     *
152     * <p>Note that any bytes that read from the returned stream won't be sent unless the stream is reset back to its
153     * initial position.</p>
154     *
155     * @return an InputStream containing the contents of this request's body.
156     */
157    public InputStream getBody() {
158        return this.body;
159    }
160
161    /**
162     * Sets the request body to the contents of an InputStream.
163     *
164     * <p>The stream must support the {@link InputStream#reset} method if auto-retry is used or if the request needs to
165     * be resent. Otherwise, the body must be manually set before each call to {@link #send}.</p>
166     *
167     * @param stream an InputStream containing the contents of the body.
168     */
169    public void setBody(InputStream stream) {
170        this.body = stream;
171    }
172
173    /**
174     * Sets the request body to the contents of an InputStream.
175     *
176     * <p>Providing the length of the InputStream allows for the progress of the request to be monitored when calling
177     * {@link #send(ProgressListener)}.</p>
178     *
179     * <p> See {@link #setBody(InputStream)} for more information on setting the body of the request.</p>
180     *
181     * @param stream an InputStream containing the contents of the body.
182     * @param length the expected length of the stream.
183     */
184    public void setBody(InputStream stream, long length) {
185        this.bodyLength = length;
186        this.body = stream;
187    }
188
189    /**
190     * Sets the request body to the contents of a String.
191     *
192     * <p>If the contents of the body are large, then it may be more efficient to use an {@link InputStream} instead of
193     * a String. Using a String requires that the entire body be in memory before sending the request.</p>
194     *
195     * @param body a String containing the contents of the body.
196     */
197    public void setBody(String body) {
198        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
199        this.bodyLength = bytes.length;
200        this.body = new ByteArrayInputStream(bytes);
201    }
202
203    /**
204     * Gets the URL from the request.
205     *
206     * @return a URL containing the URL of the request.
207     */
208    public URL getUrl() {
209        return this.url;
210    }
211
212    /**
213     * Gets the http method from the request.
214     *
215     * @return http method
216     */
217    public String getMethod() {
218        return this.method;
219    }
220
221    /**
222     * Get headers as list of RequestHeader objects.
223     * @return headers as list of RequestHeader objects
224     */
225    protected List<RequestHeader> getHeaders() {
226        return this.headers;
227    }
228
229    /**
230     * Sends this request and returns a BoxAPIResponse containing the server's response.
231     *
232     * <p>The type of the returned BoxAPIResponse will be based on the content type returned by the server, allowing it
233     * to be cast to a more specific type. For example, if it's known that the API call will return a JSON response,
234     * then it can be cast to a {@link BoxJSONResponse} like so:</p>
235     *
236     * <pre>BoxJSONResponse response = (BoxJSONResponse) request.send();</pre>
237     *
238     * <p>If the server returns an error code or if a network error occurs, then the request will be automatically
239     * retried. If the maximum number of retries is reached and an error still occurs, then a {@link BoxAPIException}
240     * will be thrown.</p>
241     *
242     * @throws BoxAPIException if the server returns an error code or if a network error occurs.
243     * @return a {@link BoxAPIResponse} containing the server's response.
244     */
245    public BoxAPIResponse send() {
246        return this.send(null);
247    }
248
249    /**
250     * Sends this request while monitoring its progress and returns a BoxAPIResponse containing the server's response.
251     *
252     * <p>A ProgressListener is generally only useful when the size of the request is known beforehand. If the size is
253     * unknown, then the ProgressListener will be updated for each byte sent, but the total number of bytes will be
254     * reported as 0.</p>
255     *
256     * <p> See {@link #send} for more information on sending requests.</p>
257     *
258     * @param  listener a listener for monitoring the progress of the request.
259     * @throws BoxAPIException if the server returns an error code or if a network error occurs.
260     * @return a {@link BoxAPIResponse} containing the server's response.
261     */
262    public BoxAPIResponse send(ProgressListener listener) {
263        if (this.api == null) {
264            this.backoffCounter.reset(BoxAPIConnection.DEFAULT_MAX_ATTEMPTS);
265        } else {
266            this.backoffCounter.reset(this.api.getMaxRequestAttempts());
267        }
268
269        while (this.backoffCounter.getAttemptsRemaining() > 0) {
270            try {
271                return this.trySend(listener);
272            } catch (BoxAPIException apiException) {
273                if (!this.backoffCounter.decrement() || !isResponseRetryable(apiException.getResponseCode())) {
274                    throw apiException;
275                }
276
277                try {
278                    this.resetBody();
279                } catch (IOException ioException) {
280                    throw apiException;
281                }
282
283                try {
284                    this.backoffCounter.waitBackoff();
285                } catch (InterruptedException interruptedException) {
286                    Thread.currentThread().interrupt();
287                    throw apiException;
288                }
289            }
290        }
291
292        throw new RuntimeException();
293    }
294
295    /**
296     * Returns a String containing the URL, HTTP method, headers and body of this request.
297     * @return a String containing information about this request.
298     */
299    @Override
300    public String toString() {
301        String lineSeparator = System.getProperty("line.separator");
302        StringBuilder builder = new StringBuilder();
303        builder.append("Request");
304        builder.append(lineSeparator);
305        builder.append(this.method);
306        builder.append(' ');
307        builder.append(this.url.toString());
308        builder.append(lineSeparator);
309
310        for (Map.Entry<String, List<String>> entry : this.requestProperties.entrySet()) {
311            List<String> nonEmptyValues = new ArrayList<String>();
312            for (String value : entry.getValue()) {
313                if (value != null && value.trim().length() != 0) {
314                    nonEmptyValues.add(value);
315                }
316            }
317
318            if (nonEmptyValues.size() == 0) {
319                continue;
320            }
321
322            builder.append(entry.getKey());
323            builder.append(": ");
324            for (String value : nonEmptyValues) {
325                builder.append(value);
326                builder.append(", ");
327            }
328
329            builder.delete(builder.length() - 2, builder.length());
330            builder.append(lineSeparator);
331        }
332
333        String bodyString = this.bodyToString();
334        if (bodyString != null) {
335            builder.append(lineSeparator);
336            builder.append(bodyString);
337        }
338
339        return builder.toString().trim();
340    }
341
342    /**
343     * Returns a String representation of this request's body used in {@link #toString}. This method returns
344     * null by default.
345     *
346     * <p>A subclass may want override this method if the body can be converted to a String for logging or debugging
347     * purposes.</p>
348     *
349     * @return a String representation of this request's body.
350     */
351    protected String bodyToString() {
352        return null;
353    }
354
355    /**
356     * Writes the body of this request to an HttpURLConnection.
357     *
358     * <p>Subclasses overriding this method must remember to close the connection's OutputStream after writing.</p>
359     *
360     * @param connection the connection to which the body should be written.
361     * @param listener   an optional listener for monitoring the write progress.
362     * @throws BoxAPIException if an error occurs while writing to the connection.
363     */
364    protected void writeBody(HttpURLConnection connection, ProgressListener listener) {
365        if (this.body == null) {
366            return;
367        }
368
369        connection.setDoOutput(true);
370        try {
371            OutputStream output = connection.getOutputStream();
372            if (listener != null) {
373                output = new ProgressOutputStream(output, listener, this.bodyLength);
374            }
375            int b = this.body.read();
376            while (b != -1) {
377                output.write(b);
378                b = this.body.read();
379            }
380            output.close();
381        } catch (IOException e) {
382            throw new BoxAPIException("Couldn't connect to the Box API due to a network error.", e);
383        }
384    }
385
386    /**
387     * Resets the InputStream containing this request's body.
388     *
389     * <p>This method will be called before each attempt to resend the request, giving subclasses an opportunity to
390     * reset any streams that need to be read when sending the body.</p>
391     *
392     * @throws IOException if the stream cannot be reset.
393     */
394    protected void resetBody() throws IOException {
395        if (this.body != null) {
396            this.body.reset();
397        }
398    }
399
400    void setBackoffCounter(BackoffCounter counter) {
401        this.backoffCounter = counter;
402    }
403
404    private BoxAPIResponse trySend(ProgressListener listener) {
405        if (this.api != null) {
406            RequestInterceptor interceptor = this.api.getRequestInterceptor();
407            if (interceptor != null) {
408                BoxAPIResponse response = interceptor.onRequest(this);
409                if (response != null) {
410                    return response;
411                }
412            }
413        }
414
415        HttpURLConnection connection = this.createConnection();
416
417        if (this.bodyLength > 0) {
418            connection.setFixedLengthStreamingMode((int) this.bodyLength);
419            connection.setDoOutput(true);
420        }
421
422        if (this.api != null) {
423            if (this.shouldAuthenticate) {
424                connection.addRequestProperty(HttpHeaders.AUTHORIZATION, "Bearer " + this.api.lockAccessToken());
425            }
426            connection.setRequestProperty("User-Agent", this.api.getUserAgent());
427            if (this.api.getProxy() != null) {
428                if (this.api.getProxyUsername() != null && this.api.getProxyPassword() != null) {
429                    String usernameAndPassword = this.api.getProxyUsername() + ":" + this.api.getProxyPassword();
430                    String encoded = new String(Base64.encode(usernameAndPassword.getBytes()));
431                    connection.addRequestProperty("Proxy-Authorization", "Basic " + encoded);
432                }
433            }
434
435            if (this.api instanceof SharedLinkAPIConnection) {
436                SharedLinkAPIConnection sharedItemAPI = (SharedLinkAPIConnection) this.api;
437                String sharedLink = sharedItemAPI.getSharedLink();
438                String boxAPIValue = "shared_link=" + sharedLink;
439                String sharedLinkPassword = sharedItemAPI.getSharedLinkPassword();
440                if (sharedLinkPassword != null) {
441                    boxAPIValue += "&shared_link_password=" + sharedLinkPassword;
442                }
443                connection.addRequestProperty("BoxApi", boxAPIValue);
444            }
445        }
446
447        this.requestProperties = connection.getRequestProperties();
448
449        int responseCode;
450        try {
451            this.writeBody(connection, listener);
452
453            // Ensure that we're connected in case writeBody() didn't write anything.
454            try {
455                connection.connect();
456            } catch (IOException e) {
457                throw new BoxAPIException("Couldn't connect to the Box API due to a network error.", e);
458            }
459
460            this.logRequest(connection);
461
462            // We need to manually handle redirects by creating a new HttpURLConnection so that connection pooling
463            // happens correctly. There seems to be a bug in Oracle's Java implementation where automatically handled
464            // redirects will not keep the connection alive.
465            try {
466                responseCode = connection.getResponseCode();
467            } catch (IOException e) {
468                throw new BoxAPIException("Couldn't connect to the Box API due to a network error.", e);
469            }
470        } finally {
471            if (this.api != null && this.shouldAuthenticate) {
472                this.api.unlockAccessToken();
473            }
474        }
475
476        if (isResponseRedirect(responseCode)) {
477            return this.handleRedirect(connection, listener);
478        }
479
480        String contentType = connection.getContentType();
481        BoxAPIResponse response;
482        if (contentType == null) {
483            response = new BoxAPIResponse(connection);
484        } else if (contentType.contains("application/json")) {
485            response = new BoxJSONResponse(connection);
486        } else {
487            response = new BoxAPIResponse(connection);
488        }
489
490        return response;
491    }
492
493    private BoxAPIResponse handleRedirect(HttpURLConnection connection, ProgressListener listener) {
494        if (this.numRedirects >= MAX_REDIRECTS) {
495            throw new BoxAPIException("The Box API responded with too many redirects.");
496        }
497        this.numRedirects++;
498
499        // Even though the redirect response won't have a body, we need to read the InputStream so that Java will put
500        // the connection back in the connection pool.
501        try {
502            InputStream stream = connection.getInputStream();
503            byte[] buffer = new byte[8192];
504            int n = stream.read(buffer);
505            while (n != -1) {
506                n = stream.read(buffer);
507            }
508            stream.close();
509        } catch (IOException e) {
510            throw new BoxAPIException("Couldn't connect to the Box API due to a network error.", e);
511        }
512
513        String redirect = connection.getHeaderField("Location");
514        try {
515            this.url = new URL(redirect);
516        } catch (MalformedURLException e) {
517            throw new BoxAPIException("The Box API responded with an invalid redirect.", e);
518        }
519
520        if (this.followRedirects) {
521            return this.trySend(listener);
522        } else {
523            BoxRedirectResponse redirectResponse = new BoxRedirectResponse();
524            redirectResponse.setRedirectURL(this.url);
525            return redirectResponse;
526        }
527    }
528
529    private void logRequest(HttpURLConnection connection) {
530        if (LOGGER.isLoggable(Level.FINE)) {
531            LOGGER.log(Level.FINE, this.toString());
532        }
533    }
534
535    private HttpURLConnection createConnection() {
536        HttpURLConnection connection = null;
537
538        try {
539            if (this.api == null || this.api.getProxy() == null) {
540                connection = (HttpURLConnection) this.url.openConnection();
541            } else {
542                connection = (HttpURLConnection) this.url.openConnection(this.api.getProxy());
543            }
544        } catch (IOException e) {
545            throw new BoxAPIException("Couldn't connect to the Box API due to a network error.", e);
546        }
547
548        try {
549            connection.setRequestMethod(this.method);
550        } catch (ProtocolException e) {
551            throw new BoxAPIException("Couldn't connect to the Box API because the request's method was invalid.", e);
552        }
553
554        connection.setConnectTimeout(this.connectTimeout);
555        connection.setReadTimeout(this.readTimeout);
556
557        // Don't allow HttpURLConnection to automatically redirect because it messes up the connection pool. See the
558        // trySend(ProgressListener) method for how we handle redirects.
559        connection.setInstanceFollowRedirects(false);
560
561        for (RequestHeader header : this.headers) {
562            connection.addRequestProperty(header.getKey(), header.getValue());
563        }
564
565        return connection;
566    }
567
568    void shouldAuthenticate(boolean shouldAuthenticate) {
569        this.shouldAuthenticate = shouldAuthenticate;
570    }
571
572    private static boolean isResponseRetryable(int responseCode) {
573        return (responseCode >= 500 || responseCode == 429);
574    }
575    private static boolean isResponseRedirect(int responseCode) {
576        return (responseCode == 301 || responseCode == 302);
577    }
578
579    /**
580     * Class for mapping a request header and value.
581     */
582    public final class RequestHeader {
583        private final String key;
584        private final String value;
585
586        /**
587         * Construct a request header from header key and value.
588         * @param key header name
589         * @param value header value
590         */
591        public RequestHeader(String key, String value) {
592            this.key = key;
593            this.value = value;
594        }
595
596        /**
597         * Get header key.
598         * @return http header name
599         */
600        public String getKey() {
601            return this.key;
602        }
603
604        /**
605         * Get header value.
606         * @return http header value
607         */
608        public String getValue() {
609            return this.value;
610        }
611    }
612}