001package com.box.sdk;
002
003import static com.box.sdk.StandardCharsets.UTF_8;
004import static com.box.sdk.http.ContentType.APPLICATION_JSON;
005import static java.lang.String.format;
006
007import com.eclipsesource.json.Json;
008import com.eclipsesource.json.ParseException;
009import java.io.ByteArrayInputStream;
010import java.io.Closeable;
011import java.io.IOException;
012import java.io.InputStream;
013import java.io.InputStreamReader;
014import java.net.HttpURLConnection;
015import java.util.List;
016import java.util.Map;
017import java.util.Objects;
018import java.util.Optional;
019import java.util.TreeMap;
020import okhttp3.MediaType;
021import okhttp3.Response;
022import okhttp3.ResponseBody;
023
024/**
025 * Used to read HTTP responses from the Box API.
026 *
027 * <p>
028 * All responses from the REST API are read using this class or one of its subclasses. This class wraps {@link
029 * HttpURLConnection} in order to provide a simpler interface that can automatically handle various conditions specific
030 * to Box's API. When a response is contructed, it will throw a {@link BoxAPIException} if the response from the API
031 * was an error. Therefore every BoxAPIResponse instance is guaranteed to represent a successful response.
032 * </p>
033 *
034 * <p>
035 * This class usually isn't instantiated directly, but is instead returned after calling {@link BoxAPIRequest#send}.
036 * </p>
037 */
038public class BoxAPIResponse implements Closeable {
039    private static final int BUFFER_SIZE = 8192;
040    private static final BoxLogger LOGGER = BoxLogger.defaultLogger();
041    private final Map<String, List<String>> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
042    private final long contentLength;
043    private final String contentType;
044    private final String requestMethod;
045    private final String requestUrl;
046    private int responseCode;
047    private String bodyString;
048
049    /**
050     * The raw InputStream is the stream returned directly from HttpURLConnection.getInputStream(). We need to keep
051     * track of this stream in case we need to access it after wrapping it inside another stream.
052     */
053    private InputStream rawInputStream;
054
055    /**
056     * The regular InputStream is the stream that will be returned by getBody(). This stream might be a GZIPInputStream
057     * or a ProgressInputStream (or both) that wrap the raw InputStream.
058     */
059    private InputStream inputStream;
060
061    /**
062     * Constructs an empty BoxAPIResponse without an associated HttpURLConnection.
063     */
064    public BoxAPIResponse() {
065        this.contentLength = 0;
066        this.contentType = null;
067        this.requestMethod = null;
068        this.requestUrl = null;
069    }
070
071    /**
072     * Constructs a BoxAPIResponse with a http response code and response headers.
073     *
074     * @param responseCode http response code
075     * @param headers      map of headers
076     */
077    public BoxAPIResponse(
078        int responseCode, String requestMethod, String requestUrl, Map<String, List<String>> headers
079    ) {
080        this(responseCode, requestMethod, requestUrl, headers, null, null, 0);
081    }
082
083    public BoxAPIResponse(int code,
084                          String requestMethod,
085                          String requestUrl,
086                          Map<String, List<String>> headers,
087                          InputStream body,
088                          String contentType,
089                          long contentLength
090    ) {
091        this.responseCode = code;
092        this.requestMethod = requestMethod;
093        this.requestUrl = requestUrl;
094        if (headers != null) {
095            this.headers.putAll(headers);
096        }
097        this.rawInputStream = body;
098        this.contentType = contentType;
099        this.contentLength = contentLength;
100        storeBodyResponse(body);
101        if (isSuccess(responseCode)) {
102            this.logResponse();
103        } else {
104            this.logErrorResponse(this.responseCode);
105            throw new BoxAPIResponseException("The API returned an error code", responseCode, null, headers);
106        }
107    }
108
109    private void storeBodyResponse(InputStream body) {
110        try {
111            if (contentType != null && body != null && contentType.contains(APPLICATION_JSON) && body.available() > 0) {
112                InputStreamReader reader = new InputStreamReader(this.getBody(), UTF_8);
113                StringBuilder builder = new StringBuilder();
114                char[] buffer = new char[BUFFER_SIZE];
115
116                int read = reader.read(buffer, 0, BUFFER_SIZE);
117                while (read != -1) {
118                    builder.append(buffer, 0, read);
119                    read = reader.read(buffer, 0, BUFFER_SIZE);
120                }
121                reader.close();
122                this.disconnect();
123                bodyString = builder.toString();
124                rawInputStream = new ByteArrayInputStream(bodyString.getBytes(UTF_8));
125            }
126        } catch (IOException e) {
127            throw new RuntimeException("Cannot read body stream", e);
128        }
129    }
130
131    private static boolean isSuccess(int responseCode) {
132        return responseCode >= 200 && responseCode < 400;
133    }
134
135    static BoxAPIResponse toBoxResponse(Response response) {
136        if (!response.isSuccessful() && !response.isRedirect()) {
137            throw new BoxAPIResponseException(
138                "The API returned an error code",
139                response.code(),
140                Optional.ofNullable(response.body()).map(body -> {
141                    try {
142                        return body.string();
143                    } catch (IOException e) {
144                        throw new RuntimeException(e);
145                    }
146                }).orElse("Body was null"),
147                response.headers().toMultimap()
148            );
149        }
150        ResponseBody responseBody = response.body();
151        if (responseBody.contentLength() == 0 || responseBody.contentType() == null) {
152            return new BoxAPIResponse(response.code(),
153                response.request().method(),
154                response.request().url().toString(),
155                response.headers().toMultimap()
156            );
157        }
158        if (responseBody != null && responseBody.contentType() != null) {
159            if (responseBody.contentType().toString().contains(APPLICATION_JSON)) {
160                String bodyAsString = "";
161                try {
162                    bodyAsString = responseBody.string();
163                    return new BoxJSONResponse(response.code(),
164                        response.request().method(),
165                        response.request().url().toString(),
166                        response.headers().toMultimap(),
167                        Json.parse(bodyAsString).asObject()
168                    );
169                } catch (ParseException e) {
170                    throw new BoxAPIException(format("Error parsing JSON:\n%s", bodyAsString), e);
171                } catch (IOException e) {
172                    throw new RuntimeException("Error getting response to string", e);
173                }
174            }
175        }
176        return new BoxAPIResponse(response.code(),
177            response.request().method(),
178            response.request().url().toString(),
179            response.headers().toMultimap(),
180            responseBody.byteStream(),
181            Optional.ofNullable(responseBody.contentType()).map(MediaType::toString).orElse(null),
182            responseBody.contentLength()
183        );
184    }
185
186    /**
187     * Gets the response code returned by the API.
188     *
189     * @return the response code returned by the API.
190     */
191    public int getResponseCode() {
192        return this.responseCode;
193    }
194
195    /**
196     * Gets the length of this response's body as indicated by the "Content-Length" header.
197     *
198     * @return the length of the response's body.
199     */
200    public long getContentLength() {
201        return this.contentLength;
202    }
203
204    /**
205     * Gets the value of the given header field.
206     *
207     * @param fieldName name of the header field.
208     * @return value of the header.
209     */
210    public String getHeaderField(String fieldName) {
211        return Optional.ofNullable(this.headers.get(fieldName)).map((l) -> l.get(0)).orElse("");
212    }
213
214    /**
215     * Gets an InputStream for reading this response's body.
216     *
217     * @return an InputStream for reading the response's body.
218     */
219    public InputStream getBody() {
220        return this.getBody(null);
221    }
222
223    /**
224     * Gets an InputStream for reading this response's body which will report its read progress to a ProgressListener.
225     *
226     * @param listener a listener for monitoring the read progress of the body.
227     * @return an InputStream for reading the response's body.
228     */
229    public InputStream getBody(ProgressListener listener) {
230        if (this.inputStream == null) {
231            if (listener == null) {
232                this.inputStream = this.rawInputStream;
233            } else {
234                this.inputStream = new ProgressInputStream(this.rawInputStream, listener, this.getContentLength());
235            }
236        }
237        return this.inputStream;
238    }
239
240    /**
241     * Disconnects this response from the server and frees up any network resources. The body of this response can no
242     * longer be read after it has been disconnected.
243     */
244    public void disconnect() {
245        this.close();
246    }
247
248    /**
249     * @return A Map containg headers on this Box API Response.
250     */
251    public Map<String, List<String>> getHeaders() {
252        return this.headers;
253    }
254
255    @Override
256    public String toString() {
257        String lineSeparator = System.getProperty("line.separator");
258        StringBuilder builder = new StringBuilder();
259        builder.append("Response")
260            .append(lineSeparator)
261            .append(this.requestMethod)
262            .append(' ')
263            .append(this.requestUrl)
264            .append(lineSeparator)
265            .append(contentType != null ? "Content-Type: " + contentType + lineSeparator : "")
266            .append(headers.isEmpty() ? "" : "Headers:" + lineSeparator);
267        headers.entrySet()
268            .stream()
269            .filter(Objects::nonNull)
270            .forEach(e -> builder.append(format("%s: [%s]%s", e.getKey().toLowerCase(), e.getValue(), lineSeparator)));
271
272        String bodyString = this.bodyToString();
273        if (bodyString != null && !bodyString.equals("")) {
274            builder.append("Body:").append(lineSeparator).append(bodyString);
275        }
276
277        return builder.toString().trim();
278    }
279
280    @Override
281    public void close() {
282        try {
283            if (this.inputStream == null && this.rawInputStream != null) {
284                this.rawInputStream.close();
285            }
286            if (this.inputStream != null) {
287                this.inputStream.close();
288            }
289        } catch (IOException e) {
290            throw new BoxAPIException(
291                "Couldn't finish closing the connection to the Box API due to a network error or "
292                    + "because the stream was already closed.", e
293            );
294        }
295    }
296
297    /**
298     * Returns a string representation of this response's body. This method is used when logging this response's body.
299     * By default, it returns an empty string (to avoid accidentally logging binary data) unless the response contained
300     * an error message.
301     *
302     * @return a string representation of this response's body.
303     */
304    protected String bodyToString() {
305        return this.bodyString;
306    }
307
308    private void logResponse() {
309        if (LOGGER.isDebugEnabled()) {
310            LOGGER.debug(this.toString());
311        }
312    }
313
314    private void logErrorResponse(int responseCode) {
315        if (responseCode < 500 && LOGGER.isWarnEnabled()) {
316            LOGGER.warn(this.toString());
317        }
318        if (responseCode >= 500 && LOGGER.isErrorEnabled()) {
319            LOGGER.error(this.toString());
320        }
321    }
322
323    protected String getRequestMethod() {
324        return requestMethod;
325    }
326
327    protected String getRequestUrl() {
328        return requestUrl;
329    }
330}