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