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