001package com.box.sdk; 002 003import static com.box.sdk.StandardCharsets.UTF_8; 004import static com.box.sdk.http.ContentType.APPLICATION_JSON; 005import static java.lang.String.format; 006 007import com.eclipsesource.json.Json; 008import com.eclipsesource.json.ParseException; 009import java.io.ByteArrayInputStream; 010import java.io.Closeable; 011import java.io.IOException; 012import java.io.InputStream; 013import java.io.InputStreamReader; 014import java.net.HttpURLConnection; 015import java.util.List; 016import java.util.Map; 017import java.util.Objects; 018import java.util.Optional; 019import java.util.TreeMap; 020import okhttp3.MediaType; 021import okhttp3.Response; 022import okhttp3.ResponseBody; 023 024/** 025 * Used to read HTTP responses from the Box API. 026 * 027 * <p>All responses from the REST API are read using this class or one of its subclasses. This class 028 * wraps {@link HttpURLConnection} in order to provide a simpler interface that can automatically 029 * handle various conditions specific to Box's API. When a response is contructed, it will throw a 030 * {@link BoxAPIException} if the response from the API was an error. Therefore every BoxAPIResponse 031 * instance is guaranteed to represent a successful response. 032 * 033 * <p>This class usually isn't instantiated directly, but is instead returned after calling {@link 034 * BoxAPIRequest#send}. 035 */ 036public class BoxAPIResponse implements Closeable { 037 private static final int BUFFER_SIZE = 8192; 038 private static final BoxLogger LOGGER = BoxLogger.defaultLogger(); 039 private final Map<String, List<String>> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); 040 private final long contentLength; 041 private final String contentType; 042 private final String requestMethod; 043 private final String requestUrl; 044 private int responseCode; 045 private String bodyString; 046 047 /** 048 * The raw InputStream is the stream returned directly from HttpURLConnection.getInputStream(). We 049 * need to keep track of this stream in case we need to access it after wrapping it inside another 050 * stream. 051 */ 052 private InputStream rawInputStream; 053 054 /** 055 * The regular InputStream is the stream that will be returned by getBody(). This stream might be 056 * a GZIPInputStream or a ProgressInputStream (or both) that wrap the raw InputStream. 057 */ 058 private InputStream inputStream; 059 060 /** Constructs an empty BoxAPIResponse without an associated HttpURLConnection. */ 061 public BoxAPIResponse() { 062 this.contentLength = 0; 063 this.contentType = null; 064 this.requestMethod = null; 065 this.requestUrl = null; 066 } 067 068 /** 069 * Constructs a BoxAPIResponse with a http response code and response headers. 070 * 071 * @param responseCode http response code 072 * @param headers map of headers 073 */ 074 public BoxAPIResponse( 075 int responseCode, 076 String requestMethod, 077 String requestUrl, 078 Map<String, List<String>> headers) { 079 this(responseCode, requestMethod, requestUrl, headers, null, null, 0); 080 } 081 082 public BoxAPIResponse( 083 int responseCode, 084 String requestMethod, 085 String requestUrl, 086 Map<String, List<String>> headers, 087 String bodyString, 088 String contentType) { 089 this(responseCode, requestMethod, requestUrl, headers, null, contentType, 0, bodyString); 090 } 091 092 public BoxAPIResponse( 093 int code, 094 String requestMethod, 095 String requestUrl, 096 Map<String, List<String>> headers, 097 InputStream body, 098 String contentType, 099 long contentLength) { 100 this(code, requestMethod, requestUrl, headers, body, contentType, contentLength, null); 101 } 102 103 public BoxAPIResponse( 104 int code, 105 String requestMethod, 106 String requestUrl, 107 Map<String, List<String>> headers, 108 InputStream body, 109 String contentType, 110 long contentLength, 111 String bodyString) { 112 this.responseCode = code; 113 this.requestMethod = requestMethod; 114 this.requestUrl = requestUrl; 115 if (headers != null) { 116 this.headers.putAll(headers); 117 } 118 this.rawInputStream = body; 119 this.contentType = contentType; 120 this.contentLength = contentLength; 121 this.bodyString = bodyString; 122 if (body != null) { 123 storeBodyResponse(body); 124 } 125 if (isSuccess(responseCode)) { 126 this.logResponse(); 127 } else { 128 this.logErrorResponse(this.responseCode); 129 throw new BoxAPIResponseException( 130 "The API returned an error code", responseCode, null, headers); 131 } 132 } 133 134 private static boolean isSuccess(int responseCode) { 135 return responseCode >= 200 && responseCode < 400; 136 } 137 138 static BoxAPIResponse toBoxResponse(Response response) { 139 if (!response.isSuccessful() && !response.isRedirect()) { 140 throw new BoxAPIResponseException( 141 "The API returned an error code", 142 response.code(), 143 Optional.ofNullable(response.body()) 144 .map( 145 body -> { 146 try { 147 return body.string(); 148 } catch (IOException e) { 149 throw new RuntimeException(e); 150 } 151 }) 152 .orElse("Body was null"), 153 response.headers().toMultimap()); 154 } 155 ResponseBody responseBody = response.body(); 156 if (responseBody.contentType() == null) { 157 try { 158 return emptyContentResponse(response); 159 } finally { 160 responseBody.close(); 161 } 162 } 163 if (responseBody != null && responseBody.contentType() != null) { 164 if (responseBody.contentType().toString().contains(APPLICATION_JSON)) { 165 if (responseBody.contentLength() == 0) { 166 return emptyContentResponse(response); 167 } 168 String bodyAsString = ""; 169 try { 170 bodyAsString = responseBody.string(); 171 return new BoxJSONResponse( 172 response.code(), 173 response.request().method(), 174 response.request().url().toString(), 175 response.headers().toMultimap(), 176 Json.parse(bodyAsString).asObject()); 177 } catch (ParseException e) { 178 throw new BoxAPIException(format("Error parsing JSON:\n%s", bodyAsString), e); 179 } catch (IOException e) { 180 throw new RuntimeException("Error getting response to string", e); 181 } finally { 182 responseBody.close(); 183 } 184 } 185 } 186 return new BoxAPIResponse( 187 response.code(), 188 response.request().method(), 189 response.request().url().toString(), 190 response.headers().toMultimap(), 191 responseBody.byteStream(), 192 Optional.ofNullable(responseBody.contentType()).map(MediaType::toString).orElse(null), 193 responseBody.contentLength()); 194 } 195 196 private static BoxAPIResponse emptyContentResponse(Response response) { 197 return new BoxAPIResponse( 198 response.code(), 199 response.request().method(), 200 response.request().url().toString(), 201 response.headers().toMultimap()); 202 } 203 204 private void storeBodyResponse(InputStream body) { 205 try { 206 if (contentType != null 207 && body != null 208 && contentType.contains(APPLICATION_JSON) 209 && body.available() > 0) { 210 InputStreamReader reader = new InputStreamReader(this.getBody(), UTF_8); 211 StringBuilder builder = new StringBuilder(); 212 char[] buffer = new char[BUFFER_SIZE]; 213 214 int read = reader.read(buffer, 0, BUFFER_SIZE); 215 while (read != -1) { 216 builder.append(buffer, 0, read); 217 read = reader.read(buffer, 0, BUFFER_SIZE); 218 } 219 reader.close(); 220 this.disconnect(); 221 bodyString = builder.toString(); 222 rawInputStream = new ByteArrayInputStream(bodyString.getBytes(UTF_8)); 223 } 224 } catch (IOException e) { 225 throw new RuntimeException("Cannot read body stream", e); 226 } 227 } 228 229 /** 230 * Gets the response code returned by the API. 231 * 232 * @return the response code returned by the API. 233 */ 234 public int getResponseCode() { 235 return this.responseCode; 236 } 237 238 /** 239 * Gets the length of this response's body as indicated by the "Content-Length" header. 240 * 241 * @return the length of the response's body. 242 */ 243 public long getContentLength() { 244 return this.contentLength; 245 } 246 247 /** 248 * Gets the value of the given header field. 249 * 250 * @param fieldName name of the header field. 251 * @return value of the header. 252 */ 253 public String getHeaderField(String fieldName) { 254 return Optional.ofNullable(this.headers.get(fieldName)).map((l) -> l.get(0)).orElse(""); 255 } 256 257 /** 258 * Gets an InputStream for reading this response's body. 259 * 260 * @return an InputStream for reading the response's body. 261 */ 262 public InputStream getBody() { 263 return this.getBody(null); 264 } 265 266 /** 267 * Gets an InputStream for reading this response's body which will report its read progress to a 268 * ProgressListener. 269 * 270 * @param listener a listener for monitoring the read progress of the body. 271 * @return an InputStream for reading the response's body. 272 */ 273 public InputStream getBody(ProgressListener listener) { 274 if (this.inputStream == null) { 275 if (listener == null) { 276 this.inputStream = this.rawInputStream; 277 } else { 278 this.inputStream = 279 new ProgressInputStream(this.rawInputStream, listener, this.getContentLength()); 280 } 281 } 282 return this.inputStream; 283 } 284 285 /** 286 * Disconnects this response from the server and frees up any network resources. The body of this 287 * response can no longer be read after it has been disconnected. 288 */ 289 public void disconnect() { 290 this.close(); 291 } 292 293 /** @return A Map containg headers on this Box API Response. */ 294 public Map<String, List<String>> getHeaders() { 295 return this.headers; 296 } 297 298 @Override 299 public String toString() { 300 String lineSeparator = System.getProperty("line.separator"); 301 StringBuilder builder = new StringBuilder(); 302 builder 303 .append("Response") 304 .append(lineSeparator) 305 .append(this.requestMethod) 306 .append(' ') 307 .append(this.requestUrl) 308 .append(' ') 309 .append(this.responseCode) 310 .append(lineSeparator) 311 .append(contentType != null ? "Content-Type: " + contentType + lineSeparator : "") 312 .append(headers.isEmpty() ? "" : "Headers:" + lineSeparator); 313 headers.entrySet().stream() 314 .filter(Objects::nonNull) 315 .forEach( 316 e -> 317 builder.append( 318 format( 319 "%s: [%s]%s", 320 e.getKey().toLowerCase(java.util.Locale.ROOT), 321 e.getValue(), 322 lineSeparator))); 323 324 String bodyString = this.bodyToString(); 325 if (bodyString != null && !bodyString.equals("")) { 326 String sanitizedBodyString = 327 contentType.equals(APPLICATION_JSON) 328 ? BoxSensitiveDataSanitizer.sanitizeJsonBody(Json.parse(bodyString).asObject()) 329 .toString() 330 : bodyString; 331 builder.append("Body:").append(lineSeparator).append(sanitizedBodyString); 332 } 333 334 return builder.toString().trim(); 335 } 336 337 @Override 338 public void close() { 339 try { 340 if (this.inputStream == null && this.rawInputStream != null) { 341 this.rawInputStream.close(); 342 } 343 if (this.inputStream != null) { 344 this.inputStream.close(); 345 } 346 } catch (IOException e) { 347 throw new BoxAPIException( 348 "Couldn't finish closing the connection to the Box API due to a network error or " 349 + "because the stream was already closed.", 350 e); 351 } 352 } 353 354 /** 355 * Returns a string representation of this response's body. This method is used when logging this 356 * response's body. By default, it returns an empty string (to avoid accidentally logging binary 357 * data) unless the response contained an error message or content type is application/json. 358 * 359 * @return a string representation of this response's body. 360 */ 361 protected String bodyToString() { 362 return this.bodyString; 363 } 364 365 private void logResponse() { 366 if (LOGGER.isDebugEnabled()) { 367 LOGGER.debug(this.toString()); 368 } 369 } 370 371 private void logErrorResponse(int responseCode) { 372 if (responseCode < 500 && LOGGER.isWarnEnabled()) { 373 LOGGER.warn(this.toString()); 374 } 375 if (responseCode >= 500 && LOGGER.isErrorEnabled()) { 376 LOGGER.error(this.toString()); 377 } 378 } 379 380 protected String getRequestMethod() { 381 return requestMethod; 382 } 383 384 protected String getRequestUrl() { 385 return requestUrl; 386 } 387}