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