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 responseCode,
084                          String requestMethod,
085                          String requestUrl,
086                          Map<String, List<String>> headers,
087                          String bodyString,
088                          String contentType) {
089        this(responseCode, requestMethod, requestUrl, headers, null, contentType, 0, bodyString);
090    }
091
092    public BoxAPIResponse(int code,
093                          String requestMethod,
094                          String requestUrl,
095                          Map<String, List<String>> headers,
096                          InputStream body,
097                          String contentType,
098                          long contentLength
099    ) {
100        this(code, requestMethod, requestUrl, headers, body, contentType, contentLength, null);
101    }
102
103    public BoxAPIResponse(int code,
104                          String requestMethod,
105                          String requestUrl,
106                          Map<String, List<String>> headers,
107                          InputStream body,
108                          String contentType,
109                          long contentLength,
110                          String bodyString
111    ) {
112        this.responseCode = code;
113        this.requestMethod = requestMethod;
114        this.requestUrl = requestUrl;
115        if (headers != null) {
116            this.headers.putAll(headers);
117        }
118        this.rawInputStream = body;
119        this.contentType = contentType;
120        this.contentLength = contentLength;
121        this.bodyString = bodyString;
122        if (body != null) {
123            storeBodyResponse(body);
124        }
125        if (isSuccess(responseCode)) {
126            this.logResponse();
127        } else {
128            this.logErrorResponse(this.responseCode);
129            throw new BoxAPIResponseException("The API returned an error code", responseCode, null, headers);
130        }
131    }
132
133    private static boolean isSuccess(int responseCode) {
134        return responseCode >= 200 && responseCode < 400;
135    }
136
137    static BoxAPIResponse toBoxResponse(Response response) {
138        if (!response.isSuccessful() && !response.isRedirect()) {
139            throw new BoxAPIResponseException(
140                "The API returned an error code",
141                response.code(),
142                Optional.ofNullable(response.body()).map(body -> {
143                    try {
144                        return body.string();
145                    } catch (IOException e) {
146                        throw new RuntimeException(e);
147                    }
148                }).orElse("Body was null"),
149                response.headers().toMultimap()
150            );
151        }
152        ResponseBody responseBody = response.body();
153        if (responseBody.contentType() == null) {
154            try {
155                return emptyContentResponse(response);
156            } finally {
157                responseBody.close();
158            }
159        }
160        if (responseBody != null && responseBody.contentType() != null) {
161            if (responseBody.contentType().toString().contains(APPLICATION_JSON)) {
162                if (responseBody.contentLength() == 0) {
163                    return emptyContentResponse(response);
164                }
165                String bodyAsString = "";
166                try {
167                    bodyAsString = responseBody.string();
168                    return new BoxJSONResponse(response.code(),
169                        response.request().method(),
170                        response.request().url().toString(),
171                        response.headers().toMultimap(),
172                        Json.parse(bodyAsString).asObject()
173                    );
174                } catch (ParseException e) {
175                    throw new BoxAPIException(format("Error parsing JSON:\n%s", bodyAsString), e);
176                } catch (IOException e) {
177                    throw new RuntimeException("Error getting response to string", e);
178                } finally {
179                    responseBody.close();
180                }
181            }
182        }
183        return new BoxAPIResponse(response.code(),
184            response.request().method(),
185            response.request().url().toString(),
186            response.headers().toMultimap(),
187            responseBody.byteStream(),
188            Optional.ofNullable(responseBody.contentType()).map(MediaType::toString).orElse(null),
189            responseBody.contentLength()
190        );
191    }
192
193    private static BoxAPIResponse emptyContentResponse(Response response) {
194        return new BoxAPIResponse(response.code(),
195            response.request().method(),
196            response.request().url().toString(),
197            response.headers().toMultimap()
198        );
199    }
200
201    private void storeBodyResponse(InputStream body) {
202        try {
203            if (contentType != null && body != null && contentType.contains(APPLICATION_JSON) && body.available() > 0) {
204                InputStreamReader reader = new InputStreamReader(this.getBody(), UTF_8);
205                StringBuilder builder = new StringBuilder();
206                char[] buffer = new char[BUFFER_SIZE];
207
208                int read = reader.read(buffer, 0, BUFFER_SIZE);
209                while (read != -1) {
210                    builder.append(buffer, 0, read);
211                    read = reader.read(buffer, 0, BUFFER_SIZE);
212                }
213                reader.close();
214                this.disconnect();
215                bodyString = builder.toString();
216                rawInputStream = new ByteArrayInputStream(bodyString.getBytes(UTF_8));
217            }
218        } catch (IOException e) {
219            throw new RuntimeException("Cannot read body stream", e);
220        }
221    }
222
223    /**
224     * Gets the response code returned by the API.
225     *
226     * @return the response code returned by the API.
227     */
228    public int getResponseCode() {
229        return this.responseCode;
230    }
231
232    /**
233     * Gets the length of this response's body as indicated by the "Content-Length" header.
234     *
235     * @return the length of the response's body.
236     */
237    public long getContentLength() {
238        return this.contentLength;
239    }
240
241    /**
242     * Gets the value of the given header field.
243     *
244     * @param fieldName name of the header field.
245     * @return value of the header.
246     */
247    public String getHeaderField(String fieldName) {
248        return Optional.ofNullable(this.headers.get(fieldName)).map((l) -> l.get(0)).orElse("");
249    }
250
251    /**
252     * Gets an InputStream for reading this response's body.
253     *
254     * @return an InputStream for reading the response's body.
255     */
256    public InputStream getBody() {
257        return this.getBody(null);
258    }
259
260    /**
261     * Gets an InputStream for reading this response's body which will report its read progress to a ProgressListener.
262     *
263     * @param listener a listener for monitoring the read progress of the body.
264     * @return an InputStream for reading the response's body.
265     */
266    public InputStream getBody(ProgressListener listener) {
267        if (this.inputStream == null) {
268            if (listener == null) {
269                this.inputStream = this.rawInputStream;
270            } else {
271                this.inputStream = new ProgressInputStream(this.rawInputStream, listener, this.getContentLength());
272            }
273        }
274        return this.inputStream;
275    }
276
277    /**
278     * Disconnects this response from the server and frees up any network resources. The body of this response can no
279     * longer be read after it has been disconnected.
280     */
281    public void disconnect() {
282        this.close();
283    }
284
285    /**
286     * @return A Map containg headers on this Box API Response.
287     */
288    public Map<String, List<String>> getHeaders() {
289        return this.headers;
290    }
291
292    @Override
293    public String toString() {
294        String lineSeparator = System.getProperty("line.separator");
295        StringBuilder builder = new StringBuilder();
296        builder.append("Response")
297            .append(lineSeparator)
298            .append(this.requestMethod)
299            .append(' ')
300            .append(this.requestUrl)
301            .append(' ')
302            .append(this.responseCode)
303            .append(lineSeparator)
304            .append(contentType != null ? "Content-Type: " + contentType + lineSeparator : "")
305            .append(headers.isEmpty() ? "" : "Headers:" + lineSeparator);
306        headers.entrySet()
307            .stream()
308            .filter(Objects::nonNull)
309            .forEach(e -> builder.append(format("%s: [%s]%s", e.getKey().toLowerCase(java.util.Locale.ROOT),
310                e.getValue(), lineSeparator)));
311
312        String bodyString = this.bodyToString();
313        if (bodyString != null && !bodyString.equals("")) {
314            String sanitizedBodyString = contentType.equals(APPLICATION_JSON)
315                ? BoxSensitiveDataSanitizer.sanitizeJsonBody(Json.parse(bodyString).asObject()).toString()
316                : bodyString;
317            builder.append("Body:").append(lineSeparator).append(sanitizedBodyString);
318        }
319
320        return builder.toString().trim();
321    }
322
323    @Override
324    public void close() {
325        try {
326            if (this.inputStream == null && this.rawInputStream != null) {
327                this.rawInputStream.close();
328            }
329            if (this.inputStream != null) {
330                this.inputStream.close();
331            }
332        } catch (IOException e) {
333            throw new BoxAPIException(
334                "Couldn't finish closing the connection to the Box API due to a network error or "
335                    + "because the stream was already closed.", e
336            );
337        }
338    }
339
340    /**
341     * Returns a string representation of this response's body. This method is used when logging this response's body.
342     * By default, it returns an empty string (to avoid accidentally logging binary data) unless the response contained
343     * an error message or content type is application/json.
344     *
345     * @return a string representation of this response's body.
346     */
347    protected String bodyToString() {
348        return this.bodyString;
349    }
350
351    private void logResponse() {
352        if (LOGGER.isDebugEnabled()) {
353            LOGGER.debug(this.toString());
354        }
355    }
356
357    private void logErrorResponse(int responseCode) {
358        if (responseCode < 500 && LOGGER.isWarnEnabled()) {
359            LOGGER.warn(this.toString());
360        }
361        if (responseCode >= 500 && LOGGER.isErrorEnabled()) {
362            LOGGER.error(this.toString());
363        }
364    }
365
366    protected String getRequestMethod() {
367        return requestMethod;
368    }
369
370    protected String getRequestUrl() {
371        return requestUrl;
372    }
373}