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 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     * Sets the URL to the request.
296     *
297     */
298    public void setUrl(URL url) {
299        this.url = url;
300    }
301
302    /**
303     * Gets the http method from the request.
304     *
305     * @return http method
306     */
307    public String getMethod() {
308        return this.method;
309    }
310
311    /**
312     * Get headers as list of RequestHeader objects.
313     *
314     * @return headers as list of RequestHeader objects
315     */
316    List<RequestHeader> getHeaders() {
317        return this.headers;
318    }
319
320    /**
321     * Sends this request and returns a BoxAPIResponse containing the server's response.
322     *
323     * <p>The type of the returned BoxAPIResponse will be based on the content type returned by the server, allowing it
324     * to be cast to a more specific type. For example, if it's known that the API call will return a JSON response,
325     * then it can be cast to a {@link BoxJSONResponse} like so:</p>
326     *
327     * <pre>BoxJSONResponse response = (BoxJSONResponse) request.sendWithoutRetry();</pre>
328     *
329     * @return a {@link BoxAPIResponse} containing the server's response.
330     * @throws BoxAPIException if the server returns an error code or if a network error occurs.
331     */
332    public BoxAPIResponse sendWithoutRetry() {
333        return this.trySend(null);
334    }
335
336    /**
337     * Sends this request and returns a BoxAPIResponse containing the server's response.
338     *
339     * <p>The type of the returned BoxAPIResponse will be based on the content type returned by the server, allowing it
340     * to be cast to a more specific type. For example, if it's known that the API call will return a JSON response,
341     * then it can be cast to a {@link BoxJSONResponse} like so:</p>
342     *
343     * <pre>BoxJSONResponse response = (BoxJSONResponse) request.send();</pre>
344     *
345     * <p>If the server returns an error code or if a network error occurs, then the request will be automatically
346     * retried. If the maximum number of retries is reached and an error still occurs, then a {@link BoxAPIException}
347     * will be thrown.</p>
348     *
349     * <p> See {@link #send} for more information on sending requests.</p>
350     *
351     * @return a {@link BoxAPIResponse} containing the server's response.
352     * @throws BoxAPIException if the server returns an error code or if a network error occurs.
353     */
354    public BoxAPIResponse send() {
355        return this.send(null);
356    }
357
358    /**
359     * Sends this request while monitoring its progress and returns a BoxAPIResponse containing the server's response.
360     *
361     * <p>The type of the returned BoxAPIResponse will be based on the content type returned by the server, allowing it
362     * to be cast to a more specific type. For example, if it's known that the API call will return a JSON response,
363     * then it can be cast to a {@link BoxJSONResponse} like so:</p>
364     *
365     * <p>If the server returns an error code or if a network error occurs, then the request will be automatically
366     * retried. If the maximum number of retries is reached and an error still occurs, then a {@link BoxAPIException}
367     * will be thrown.</p>
368     *
369     * <p>A ProgressListener is generally only useful when the size of the request is known beforehand. If the size is
370     * unknown, then the ProgressListener will be updated for each byte sent, but the total number of bytes will be
371     * reported as 0.</p>
372     *
373     * <p> See {@link #send} for more information on sending requests.</p>
374     *
375     * @param listener a listener for monitoring the progress of the request.
376     * @return a {@link BoxAPIResponse} containing the server's response.
377     * @throws BoxAPIException if the server returns an error code or if a network error occurs.
378     */
379    public BoxAPIResponse send(ProgressListener listener) {
380        if (this.api == null) {
381            this.backoffCounter.reset(BoxGlobalSettings.getMaxRetryAttempts() + 1);
382        } else {
383            this.backoffCounter.reset(this.api.getMaxRetryAttempts() + 1);
384        }
385
386        while (this.backoffCounter.getAttemptsRemaining() > 0) {
387            try {
388                return this.trySend(listener);
389            } catch (BoxAPIException apiException) {
390                if (!this.backoffCounter.decrement()
391                    || (!isRequestRetryable(apiException)
392                    && !isResponseRetryable(apiException.getResponseCode(), apiException))) {
393                    throw apiException;
394                }
395
396                LOGGER.warn(
397                    format("Retrying request due to transient error status=%d body=%s headers=%s",
398                        apiException.getResponseCode(),
399                        apiException.getResponse(),
400                        mapToString(apiException.getHeaders()))
401                );
402
403                try {
404                    this.resetBody();
405                } catch (IOException ioException) {
406                    throw apiException;
407                }
408
409                try {
410                    List<String> retryAfterHeader = apiException.getHeaders().get("Retry-After");
411                    if (retryAfterHeader == null) {
412                        this.backoffCounter.waitBackoff();
413                    } else {
414                        int retryAfterDelay = Integer.parseInt(retryAfterHeader.get(0)) * 1000;
415                        this.backoffCounter.waitBackoff(retryAfterDelay);
416                    }
417                } catch (InterruptedException interruptedException) {
418                    Thread.currentThread().interrupt();
419                    throw apiException;
420                }
421            }
422        }
423
424        throw new RuntimeException();
425    }
426
427    /**
428     * Disables adding authentication header to request.
429     * Useful when you want to add your own authentication method.
430     * Default value is `true` and SKD will add authenticaton header to request.
431     *
432     * @param shouldAuthenticate use `false` to disable authentication.
433     */
434    public void shouldAuthenticate(boolean shouldAuthenticate) {
435        this.shouldAuthenticate = shouldAuthenticate;
436    }
437
438    /**
439     * Sends a request to upload a file part and returns a BoxFileUploadSessionPart containing information
440     * about the upload part. This method is separate from send() because it has custom retry logic.
441     *
442     * <p>If the server returns an error code or if a network error occurs, then the request will be automatically
443     * retried. If the maximum number of retries is reached and an error still occurs, then a {@link BoxAPIException}
444     * will be thrown.</p>
445     *
446     * @param session The BoxFileUploadSession uploading the part
447     * @param offset  Offset of the part being uploaded
448     * @return A {@link BoxFileUploadSessionPart} part that has been uploaded.
449     * @throws BoxAPIException if the server returns an error code or if a network error occurs.
450     */
451    BoxFileUploadSessionPart sendForUploadPart(BoxFileUploadSession session, long offset) {
452        if (this.api == null) {
453            this.backoffCounter.reset(BoxGlobalSettings.getMaxRetryAttempts() + 1);
454        } else {
455            this.backoffCounter.reset(this.api.getMaxRetryAttempts() + 1);
456        }
457
458        while (this.backoffCounter.getAttemptsRemaining() > 0) {
459            try (BoxJSONResponse response = (BoxJSONResponse) this.trySend(null)) {
460                // upload sends binary data but response is JSON
461                JsonObject jsonObject = Json.parse(response.getJSON()).asObject();
462                return new BoxFileUploadSessionPart((JsonObject) jsonObject.get("part"));
463            } catch (BoxAPIException apiException) {
464                if (!this.backoffCounter.decrement()
465                    || (!isRequestRetryable(apiException)
466                    && !isResponseRetryable(apiException.getResponseCode(), apiException))) {
467                    throw apiException;
468                }
469                if (apiException.getResponseCode() == 500) {
470                    try {
471                        Iterable<BoxFileUploadSessionPart> parts = session.listParts();
472                        for (BoxFileUploadSessionPart part : parts) {
473                            if (part.getOffset() == offset) {
474                                return part;
475                            }
476                        }
477                    } catch (BoxAPIException e) {
478                        // ignoring exception as we are retrying
479                    }
480                }
481                LOGGER.warn(format(
482                    "Retrying request due to transient error status=%d body=%s",
483                    apiException.getResponseCode(),
484                    apiException.getResponse()
485                ));
486
487                try {
488                    this.resetBody();
489                } catch (IOException ioException) {
490                    throw apiException;
491                }
492
493                try {
494                    this.backoffCounter.waitBackoff();
495                } catch (InterruptedException interruptedException) {
496                    Thread.currentThread().interrupt();
497                    throw apiException;
498                }
499            }
500        }
501
502        throw new RuntimeException();
503    }
504
505    /**
506     * Returns a String containing the URL, HTTP method, headers and body of this request.
507     *
508     * @return a String containing information about this request.
509     */
510    @Override
511    public String toString() {
512        String lineSeparator = System.getProperty("line.separator");
513        StringBuilder builder = new StringBuilder();
514        builder.append("Request");
515        builder.append(lineSeparator);
516        builder.append(this.method);
517        builder.append(' ');
518        builder.append(this.url.toString());
519        builder.append(lineSeparator);
520
521        String bodyString = this.bodyToString();
522        if (bodyString != null) {
523            builder.append(lineSeparator);
524            builder.append(bodyString);
525        }
526
527        return builder.toString().trim();
528    }
529
530    /**
531     * Returns a String representation of this request's body used in {@link #toString}. This method returns
532     * null by default.
533     *
534     * <p>A subclass may want override this method if the body can be converted to a String for logging or debugging
535     * purposes.</p>
536     *
537     * @return a String representation of this request's body.
538     */
539    protected String bodyToString() {
540        return null;
541    }
542
543    private void writeWithBuffer(OutputStream output, ProgressListener listener) {
544        try {
545            OutputStream finalOutput = output;
546            if (listener != null) {
547                finalOutput = new ProgressOutputStream(output, listener, this.bodyLength);
548            }
549            byte[] buffer = new byte[BUFFER_SIZE];
550            int b = this.body.read(buffer);
551            while (b != -1) {
552                finalOutput.write(buffer, 0, b);
553                b = this.body.read(buffer);
554            }
555        } catch (IOException e) {
556            throw new RuntimeException("Error writting body", e);
557        }
558    }
559
560    /**
561     * Resets the InputStream containing this request's body.
562     *
563     * <p>This method will be called before each attempt to resend the request, giving subclasses an opportunity to
564     * reset any streams that need to be read when sending the body.</p>
565     *
566     * @throws IOException if the stream cannot be reset.
567     */
568    protected void resetBody() throws IOException {
569        if (this.body != null) {
570            this.body.reset();
571        }
572    }
573
574    void setBackoffCounter(BackoffCounter counter) {
575        this.backoffCounter = counter;
576    }
577
578    private BoxAPIResponse trySend(ProgressListener listener) {
579        if (this.api != null) {
580            RequestInterceptor interceptor = this.api.getRequestInterceptor();
581            if (interceptor != null) {
582                BoxAPIResponse response = interceptor.onRequest(this);
583                if (response != null) {
584                    return response;
585                }
586            }
587        }
588        long start = System.currentTimeMillis();
589        Request request = composeRequest(listener);
590        Response response;
591        if (this.followRedirects) {
592            response = api.execute(request);
593        } else {
594            response = api.executeWithoutRedirect(request);
595        }
596        logDebug(format("[trySend] connection.connect() took %dms%n", (System.currentTimeMillis() - start)));
597
598        BoxAPIResponse result = BoxAPIResponse.toBoxResponse(response);
599        this.logRequest();
600        long getResponseStart = System.currentTimeMillis();
601        logDebug(format(
602            "[trySend] Get Response (read network) took %dms%n", System.currentTimeMillis() - getResponseStart
603        ));
604        return result;
605    }
606
607    private Request composeRequest(ProgressListener listener) {
608        Request.Builder requestBuilder = new Request.Builder().url(getUrl());
609        if (this.shouldAuthenticate) {
610            requestBuilder.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + this.api.lockAccessToken());
611        }
612        try {
613            requestBuilder.addHeader("User-Agent", this.api.getUserAgent());
614            requestBuilder.addHeader("X-Box-UA", this.api.getBoxUAHeader());
615            headers.forEach(h -> {
616                requestBuilder.removeHeader(h.getKey());
617                requestBuilder.addHeader(h.getKey(), h.getValue());
618            });
619
620            if (this.api instanceof SharedLinkAPIConnection) {
621                SharedLinkAPIConnection sharedItemAPI = (SharedLinkAPIConnection) this.api;
622                String boxAPIValue = BoxSharedLink.getSharedLinkHeaderValue(
623                        sharedItemAPI.getSharedLink(),
624                        sharedItemAPI.getSharedLinkPassword()
625                );
626                requestBuilder.addHeader("BoxApi", boxAPIValue);
627            }
628
629
630            writeMethodWithBody(requestBuilder, listener);
631            return requestBuilder.build();
632        } finally {
633            if (this.shouldAuthenticate) {
634                this.api.unlockAccessToken();
635            }
636        }
637    }
638
639    protected void writeMethodWithBody(Request.Builder requestBuilder, ProgressListener listener) {
640        ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream();
641        if (body != null) {
642            long writeStart = System.currentTimeMillis();
643            writeWithBuffer(bodyBytes, listener);
644            logDebug(format("[trySend] Body write took %dms%n", (System.currentTimeMillis() - writeStart)));
645        }
646        if (method.equals("GET")) {
647            requestBuilder.get();
648        }
649        if (method.equals("DELETE")) {
650            requestBuilder.delete();
651        }
652        if (method.equals("OPTIONS")) {
653            if (body == null) {
654                requestBuilder.method("OPTIONS", null);
655            } else {
656                requestBuilder.method("OPTIONS", RequestBody.create(bodyBytes.toByteArray(), mediaType()));
657            }
658        }
659        if (method.equals("POST")) {
660            requestBuilder.post(RequestBody.create(bodyBytes.toByteArray(), mediaType()));
661        }
662        if (method.equals("PUT")) {
663            requestBuilder.put(RequestBody.create(bodyBytes.toByteArray(), mediaType()));
664        }
665    }
666
667    private void logDebug(String message) {
668        if (LOGGER.isDebugEnabled()) {
669            LOGGER.debug(message);
670        }
671    }
672
673    private void logRequest() {
674        logDebug(this.toString());
675    }
676
677    /**
678     * Class for mapping a request header and value.
679     */
680    public static final class RequestHeader {
681        private final String key;
682        private final String value;
683
684        /**
685         * Construct a request header from header key and value.
686         *
687         * @param key   header name
688         * @param value header value
689         */
690        public RequestHeader(String key, String value) {
691            this.key = key;
692            this.value = value;
693        }
694
695        /**
696         * Get header key.
697         *
698         * @return http header name
699         */
700        public String getKey() {
701            return this.key;
702        }
703
704        /**
705         * Get header value.
706         *
707         * @return http header value
708         */
709        public String getValue() {
710            return this.value;
711        }
712    }
713
714    protected MediaType mediaType() {
715        return MediaType.parse(mediaType);
716    }
717}