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