001package com.box.sdk;
002
003import java.io.IOException;
004import java.io.InputStream;
005import java.io.InputStreamReader;
006import java.net.HttpURLConnection;
007import java.util.ArrayList;
008import java.util.List;
009import java.util.Map;
010import java.util.TreeMap;
011import java.util.zip.GZIPInputStream;
012
013/**
014 * Used to read HTTP responses from the Box API.
015 *
016 * <p>
017 * All responses from the REST API are read using this class or one of its subclasses. This class wraps {@link
018 * HttpURLConnection} in order to provide a simpler interface that can automatically handle various conditions specific
019 * to Box's API. When a response is contructed, it will throw a {@link BoxAPIException} if the response from the API
020 * was an error. Therefore every BoxAPIResponse instance is guaranteed to represent a successful response.
021 * </p>
022 *
023 * <p>
024 * This class usually isn't instantiated directly, but is instead returned after calling {@link BoxAPIRequest#send}.
025 * </p>
026 */
027public class BoxAPIResponse {
028    private static final BoxLogger LOGGER = BoxLogger.defaultLogger();
029    private static final int BUFFER_SIZE = 8192;
030
031    private final HttpURLConnection connection;
032    //Batch API Response will have headers in response body
033    private final Map<String, String> headers;
034
035    private int responseCode;
036    private String bodyString;
037
038    /**
039     * The raw InputStream is the stream returned directly from HttpURLConnection.getInputStream(). We need to keep
040     * track of this stream in case we need to access it after wrapping it inside another stream.
041     */
042    private InputStream rawInputStream;
043
044    /**
045     * The regular InputStream is the stream that will be returned by getBody(). This stream might be a GZIPInputStream
046     * or a ProgressInputStream (or both) that wrap the raw InputStream.
047     */
048    private InputStream inputStream;
049
050    /**
051     * Constructs an empty BoxAPIResponse without an associated HttpURLConnection.
052     */
053    public BoxAPIResponse() {
054        this.connection = null;
055        this.headers = null;
056    }
057
058    /**
059     * Constructs a BoxAPIResponse with a http response code and response headers.
060     *
061     * @param responseCode http response code
062     * @param headers      map of headers
063     */
064    public BoxAPIResponse(int responseCode, Map<String, String> headers) {
065        this.connection = null;
066        this.responseCode = responseCode;
067        this.headers = headers;
068    }
069
070    /**
071     * Constructs a BoxAPIResponse using an HttpURLConnection.
072     *
073     * @param connection a connection that has already sent a request to the API.
074     */
075    public BoxAPIResponse(HttpURLConnection connection) {
076        this.connection = connection;
077        this.inputStream = null;
078
079        try {
080            this.responseCode = this.connection.getResponseCode();
081        } catch (IOException e) {
082            throw new BoxAPIException("Couldn't connect to the Box API due to a network error.", e);
083        }
084
085        Map<String, String> responseHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
086        for (String headerKey : connection.getHeaderFields().keySet()) {
087            if (headerKey != null) {
088                responseHeaders.put(headerKey, connection.getHeaderField(headerKey));
089            }
090        }
091        this.headers = responseHeaders;
092
093        if (!isSuccess(this.responseCode)) {
094            this.logErrorResponse(this.responseCode);
095            throw new BoxAPIResponseException("The API returned an error code", this);
096        }
097
098        this.logResponse();
099    }
100
101    private static boolean isSuccess(int responseCode) {
102        return responseCode >= 200 && responseCode < 300;
103    }
104
105    private static String readErrorStream(InputStream stream) {
106        if (stream == null) {
107            return null;
108        }
109
110        InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8);
111        StringBuilder builder = new StringBuilder();
112        char[] buffer = new char[BUFFER_SIZE];
113
114        try {
115            int read = reader.read(buffer, 0, BUFFER_SIZE);
116            while (read != -1) {
117                builder.append(buffer, 0, read);
118                read = reader.read(buffer, 0, BUFFER_SIZE);
119            }
120
121            reader.close();
122        } catch (IOException e) {
123            return null;
124        }
125
126        return builder.toString();
127    }
128
129    /**
130     * Gets the response code returned by the API.
131     *
132     * @return the response code returned by the API.
133     */
134    public int getResponseCode() {
135        return this.responseCode;
136    }
137
138    /**
139     * Gets the length of this response's body as indicated by the "Content-Length" header.
140     *
141     * @return the length of the response's body.
142     */
143    public long getContentLength() {
144        return this.connection.getContentLength();
145    }
146
147    /**
148     * Gets the value of the given header field.
149     *
150     * @param fieldName name of the header field.
151     * @return value of the header.
152     */
153    public String getHeaderField(String fieldName) {
154        // headers map is null for all regular response calls except when made as a batch request
155        if (this.headers == null) {
156            if (this.connection != null) {
157                return this.connection.getHeaderField(fieldName);
158            } else {
159                return null;
160            }
161        } else {
162            return this.headers.get(fieldName);
163        }
164    }
165
166    /**
167     * Gets an InputStream for reading this response's body.
168     *
169     * @return an InputStream for reading the response's body.
170     */
171    public InputStream getBody() {
172        return this.getBody(null);
173    }
174
175    /**
176     * Gets an InputStream for reading this response's body which will report its read progress to a ProgressListener.
177     *
178     * @param listener a listener for monitoring the read progress of the body.
179     * @return an InputStream for reading the response's body.
180     */
181    public InputStream getBody(ProgressListener listener) {
182        if (this.inputStream == null) {
183            String contentEncoding = this.connection.getContentEncoding();
184            try {
185                if (this.rawInputStream == null) {
186                    this.rawInputStream = this.connection.getInputStream();
187                }
188
189                if (listener == null) {
190                    this.inputStream = this.rawInputStream;
191                } else {
192                    this.inputStream = new ProgressInputStream(this.rawInputStream, listener,
193                        this.getContentLength());
194                }
195
196                if (contentEncoding != null && contentEncoding.equalsIgnoreCase("gzip")) {
197                    this.inputStream = new GZIPInputStream(this.inputStream);
198                }
199            } catch (IOException e) {
200                throw new BoxAPIException("Couldn't connect to the Box API due to a network error.", e);
201            }
202        }
203
204        return this.inputStream;
205    }
206
207    /**
208     * Disconnects this response from the server and frees up any network resources. The body of this response can no
209     * longer be read after it has been disconnected.
210     */
211    public void disconnect() {
212        if (this.connection == null) {
213            return;
214        }
215
216        try {
217            if (this.rawInputStream == null) {
218                this.rawInputStream = this.connection.getInputStream();
219            }
220
221            // We need to manually read from the raw input stream in case there are any remaining bytes. There's a bug
222            // where a wrapping GZIPInputStream may not read to the end of a chunked response, causing Java to not
223            // return the connection to the connection pool.
224            byte[] buffer = new byte[BUFFER_SIZE];
225            int n = this.rawInputStream.read(buffer);
226            while (n != -1) {
227                n = this.rawInputStream.read(buffer);
228            }
229            this.rawInputStream.close();
230
231            if (this.inputStream != null) {
232                this.inputStream.close();
233            }
234        } catch (IOException e) {
235            throw new BoxAPIException("Couldn't finish closing the connection to the Box API due to a network error or "
236                + "because the stream was already closed.", e);
237        }
238    }
239
240    /**
241     * @return A Map containg headers on this Box API Response.
242     */
243    public Map<String, String> getHeaders() {
244        return this.headers;
245    }
246
247    @Override
248    public String toString() {
249        String lineSeparator = System.getProperty("line.separator");
250        Map<String, List<String>> headers = this.connection.getHeaderFields();
251        StringBuilder builder = new StringBuilder();
252        builder.append("Response");
253        builder.append(lineSeparator);
254        builder.append(this.connection.getRequestMethod());
255        builder.append(' ');
256        builder.append(this.connection.getURL().toString());
257        builder.append(lineSeparator);
258        builder.append(headers.get(null).get(0));
259        builder.append(lineSeparator);
260
261        for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
262            String key = entry.getKey();
263            if (key == null) {
264                continue;
265            }
266
267            List<String> nonEmptyValues = new ArrayList<>();
268            for (String value : entry.getValue()) {
269                if (value != null && value.trim().length() != 0) {
270                    nonEmptyValues.add(value);
271                }
272            }
273
274            if (nonEmptyValues.size() == 0) {
275                continue;
276            }
277
278            builder.append(key);
279            builder.append(": ");
280            for (String value : nonEmptyValues) {
281                builder.append(value);
282                builder.append(", ");
283            }
284
285            builder.delete(builder.length() - 2, builder.length());
286            builder.append(lineSeparator);
287        }
288
289        String bodyString = this.bodyToString();
290        if (bodyString != null && !bodyString.equals("")) {
291            builder.append(lineSeparator);
292            builder.append(bodyString);
293        }
294
295        return builder.toString().trim();
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        if (this.bodyString == null && !isSuccess(this.responseCode)) {
307            this.bodyString = readErrorStream(this.getErrorStream());
308        }
309
310        return this.bodyString;
311    }
312
313    /**
314     * Returns the response error stream, handling the case when it contains gzipped data.
315     *
316     * @return gzip decoded (if needed) error stream or null
317     */
318    private InputStream getErrorStream() {
319        InputStream errorStream = this.connection.getErrorStream();
320        if (errorStream != null) {
321            final String contentEncoding = this.connection.getContentEncoding();
322            if (contentEncoding != null && contentEncoding.equalsIgnoreCase("gzip")) {
323                try {
324                    errorStream = new GZIPInputStream(errorStream);
325                } catch (IOException e) {
326                    // just return the error stream as is
327                }
328            }
329        }
330
331        return errorStream;
332    }
333
334    private void logResponse() {
335        if (LOGGER.isDebugEnabled()) {
336            LOGGER.debug(this.toString());
337        }
338    }
339
340    private void logErrorResponse(int responseCode) {
341        if (responseCode < 500 && LOGGER.isWarnEnabled()) {
342            LOGGER.warn(this.toString());
343        }
344        if (responseCode >= 500 && LOGGER.isErrorEnabled()) {
345            LOGGER.error(this.toString());
346        }
347    }
348}