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}