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.contentType() == null) {
152            try {
153                return emptyContentResponse(response);
154            } finally {
155                responseBody.close();
156            }
157        }
158        if (responseBody != null && responseBody.contentType() != null) {
159            if (responseBody.contentType().toString().contains(APPLICATION_JSON)) {
160                if (responseBody.contentLength() == 0) {
161                    return emptyContentResponse(response);
162                }
163                String bodyAsString = "";
164                try {
165                    bodyAsString = responseBody.string();
166                    return new BoxJSONResponse(response.code(),
167                        response.request().method(),
168                        response.request().url().toString(),
169                        response.headers().toMultimap(),
170                        Json.parse(bodyAsString).asObject()
171                    );
172                } catch (ParseException e) {
173                    throw new BoxAPIException(format("Error parsing JSON:\n%s", bodyAsString), e);
174                } catch (IOException e) {
175                    throw new RuntimeException("Error getting response to string", e);
176                } finally {
177                    responseBody.close();
178                }
179            }
180        }
181        return new BoxAPIResponse(response.code(),
182            response.request().method(),
183            response.request().url().toString(),
184            response.headers().toMultimap(),
185            responseBody.byteStream(),
186            Optional.ofNullable(responseBody.contentType()).map(MediaType::toString).orElse(null),
187            responseBody.contentLength()
188        );
189    }
190
191    private static BoxAPIResponse emptyContentResponse(Response response) {
192        return new BoxAPIResponse(response.code(),
193            response.request().method(),
194            response.request().url().toString(),
195            response.headers().toMultimap()
196        );
197    }
198
199    /**
200     * Gets the response code returned by the API.
201     *
202     * @return the response code returned by the API.
203     */
204    public int getResponseCode() {
205        return this.responseCode;
206    }
207
208    /**
209     * Gets the length of this response's body as indicated by the "Content-Length" header.
210     *
211     * @return the length of the response's body.
212     */
213    public long getContentLength() {
214        return this.contentLength;
215    }
216
217    /**
218     * Gets the value of the given header field.
219     *
220     * @param fieldName name of the header field.
221     * @return value of the header.
222     */
223    public String getHeaderField(String fieldName) {
224        return Optional.ofNullable(this.headers.get(fieldName)).map((l) -> l.get(0)).orElse("");
225    }
226
227    /**
228     * Gets an InputStream for reading this response's body.
229     *
230     * @return an InputStream for reading the response's body.
231     */
232    public InputStream getBody() {
233        return this.getBody(null);
234    }
235
236    /**
237     * Gets an InputStream for reading this response's body which will report its read progress to a ProgressListener.
238     *
239     * @param listener a listener for monitoring the read progress of the body.
240     * @return an InputStream for reading the response's body.
241     */
242    public InputStream getBody(ProgressListener listener) {
243        if (this.inputStream == null) {
244            if (listener == null) {
245                this.inputStream = this.rawInputStream;
246            } else {
247                this.inputStream = new ProgressInputStream(this.rawInputStream, listener, this.getContentLength());
248            }
249        }
250        return this.inputStream;
251    }
252
253    /**
254     * Disconnects this response from the server and frees up any network resources. The body of this response can no
255     * longer be read after it has been disconnected.
256     */
257    public void disconnect() {
258        this.close();
259    }
260
261    /**
262     * @return A Map containg headers on this Box API Response.
263     */
264    public Map<String, List<String>> getHeaders() {
265        return this.headers;
266    }
267
268    @Override
269    public String toString() {
270        String lineSeparator = System.getProperty("line.separator");
271        StringBuilder builder = new StringBuilder();
272        builder.append("Response")
273            .append(lineSeparator)
274            .append(this.requestMethod)
275            .append(' ')
276            .append(this.requestUrl)
277            .append(lineSeparator)
278            .append(contentType != null ? "Content-Type: " + contentType + lineSeparator : "")
279            .append(headers.isEmpty() ? "" : "Headers:" + lineSeparator);
280        headers.entrySet()
281            .stream()
282            .filter(Objects::nonNull)
283            .forEach(e -> builder.append(format("%s: [%s]%s", e.getKey().toLowerCase(), e.getValue(), lineSeparator)));
284
285        String bodyString = this.bodyToString();
286        if (bodyString != null && !bodyString.equals("")) {
287            builder.append("Body:").append(lineSeparator).append(bodyString);
288        }
289
290        return builder.toString().trim();
291    }
292
293    @Override
294    public void close() {
295        try {
296            if (this.inputStream == null && this.rawInputStream != null) {
297                this.rawInputStream.close();
298            }
299            if (this.inputStream != null) {
300                this.inputStream.close();
301            }
302        } catch (IOException e) {
303            throw new BoxAPIException(
304                "Couldn't finish closing the connection to the Box API due to a network error or "
305                    + "because the stream was already closed.", e
306            );
307        }
308    }
309
310    /**
311     * Returns a string representation of this response's body. This method is used when logging this response's body.
312     * By default, it returns an empty string (to avoid accidentally logging binary data) unless the response contained
313     * an error message.
314     *
315     * @return a string representation of this response's body.
316     */
317    protected String bodyToString() {
318        return this.bodyString;
319    }
320
321    private void logResponse() {
322        if (LOGGER.isDebugEnabled()) {
323            LOGGER.debug(this.toString());
324        }
325    }
326
327    private void logErrorResponse(int responseCode) {
328        if (responseCode < 500 && LOGGER.isWarnEnabled()) {
329            LOGGER.warn(this.toString());
330        }
331        if (responseCode >= 500 && LOGGER.isErrorEnabled()) {
332            LOGGER.error(this.toString());
333        }
334    }
335
336    protected String getRequestMethod() {
337        return requestMethod;
338    }
339
340    protected String getRequestUrl() {
341        return requestUrl;
342    }
343}