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