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