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