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