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}