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