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