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}