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