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