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 code, 084 String requestMethod, 085 String requestUrl, 086 Map<String, List<String>> headers, 087 InputStream body, 088 String contentType, 089 long contentLength 090 ) { 091 this.responseCode = code; 092 this.requestMethod = requestMethod; 093 this.requestUrl = requestUrl; 094 if (headers != null) { 095 this.headers.putAll(headers); 096 } 097 this.rawInputStream = body; 098 this.contentType = contentType; 099 this.contentLength = contentLength; 100 storeBodyResponse(body); 101 if (isSuccess(responseCode)) { 102 this.logResponse(); 103 } else { 104 this.logErrorResponse(this.responseCode); 105 throw new BoxAPIResponseException("The API returned an error code", responseCode, null, headers); 106 } 107 } 108 109 private void storeBodyResponse(InputStream body) { 110 try { 111 if (contentType != null && body != null && contentType.contains(APPLICATION_JSON) && body.available() > 0) { 112 InputStreamReader reader = new InputStreamReader(this.getBody(), UTF_8); 113 StringBuilder builder = new StringBuilder(); 114 char[] buffer = new char[BUFFER_SIZE]; 115 116 int read = reader.read(buffer, 0, BUFFER_SIZE); 117 while (read != -1) { 118 builder.append(buffer, 0, read); 119 read = reader.read(buffer, 0, BUFFER_SIZE); 120 } 121 reader.close(); 122 this.disconnect(); 123 bodyString = builder.toString(); 124 rawInputStream = new ByteArrayInputStream(bodyString.getBytes(UTF_8)); 125 } 126 } catch (IOException e) { 127 throw new RuntimeException("Cannot read body stream", e); 128 } 129 } 130 131 private static boolean isSuccess(int responseCode) { 132 return responseCode >= 200 && responseCode < 400; 133 } 134 135 static BoxAPIResponse toBoxResponse(Response response) { 136 if (!response.isSuccessful() && !response.isRedirect()) { 137 throw new BoxAPIResponseException( 138 "The API returned an error code", 139 response.code(), 140 Optional.ofNullable(response.body()).map(body -> { 141 try { 142 return body.string(); 143 } catch (IOException e) { 144 throw new RuntimeException(e); 145 } 146 }).orElse("Body was null"), 147 response.headers().toMultimap() 148 ); 149 } 150 ResponseBody responseBody = response.body(); 151 if (responseBody.contentLength() == 0 || responseBody.contentType() == null) { 152 return new BoxAPIResponse(response.code(), 153 response.request().method(), 154 response.request().url().toString(), 155 response.headers().toMultimap() 156 ); 157 } 158 if (responseBody != null && responseBody.contentType() != null) { 159 if (responseBody.contentType().toString().contains(APPLICATION_JSON)) { 160 String bodyAsString = ""; 161 try { 162 bodyAsString = responseBody.string(); 163 return new BoxJSONResponse(response.code(), 164 response.request().method(), 165 response.request().url().toString(), 166 response.headers().toMultimap(), 167 Json.parse(bodyAsString).asObject() 168 ); 169 } catch (ParseException e) { 170 throw new BoxAPIException(format("Error parsing JSON:\n%s", bodyAsString), e); 171 } catch (IOException e) { 172 throw new RuntimeException("Error getting response to string", e); 173 } 174 } 175 } 176 return new BoxAPIResponse(response.code(), 177 response.request().method(), 178 response.request().url().toString(), 179 response.headers().toMultimap(), 180 responseBody.byteStream(), 181 Optional.ofNullable(responseBody.contentType()).map(MediaType::toString).orElse(null), 182 responseBody.contentLength() 183 ); 184 } 185 186 /** 187 * Gets the response code returned by the API. 188 * 189 * @return the response code returned by the API. 190 */ 191 public int getResponseCode() { 192 return this.responseCode; 193 } 194 195 /** 196 * Gets the length of this response's body as indicated by the "Content-Length" header. 197 * 198 * @return the length of the response's body. 199 */ 200 public long getContentLength() { 201 return this.contentLength; 202 } 203 204 /** 205 * Gets the value of the given header field. 206 * 207 * @param fieldName name of the header field. 208 * @return value of the header. 209 */ 210 public String getHeaderField(String fieldName) { 211 return Optional.ofNullable(this.headers.get(fieldName)).map((l) -> l.get(0)).orElse(""); 212 } 213 214 /** 215 * Gets an InputStream for reading this response's body. 216 * 217 * @return an InputStream for reading the response's body. 218 */ 219 public InputStream getBody() { 220 return this.getBody(null); 221 } 222 223 /** 224 * Gets an InputStream for reading this response's body which will report its read progress to a ProgressListener. 225 * 226 * @param listener a listener for monitoring the read progress of the body. 227 * @return an InputStream for reading the response's body. 228 */ 229 public InputStream getBody(ProgressListener listener) { 230 if (this.inputStream == null) { 231 if (listener == null) { 232 this.inputStream = this.rawInputStream; 233 } else { 234 this.inputStream = new ProgressInputStream(this.rawInputStream, listener, this.getContentLength()); 235 } 236 } 237 return this.inputStream; 238 } 239 240 /** 241 * Disconnects this response from the server and frees up any network resources. The body of this response can no 242 * longer be read after it has been disconnected. 243 */ 244 public void disconnect() { 245 this.close(); 246 } 247 248 /** 249 * @return A Map containg headers on this Box API Response. 250 */ 251 public Map<String, List<String>> getHeaders() { 252 return this.headers; 253 } 254 255 @Override 256 public String toString() { 257 String lineSeparator = System.getProperty("line.separator"); 258 StringBuilder builder = new StringBuilder(); 259 builder.append("Response") 260 .append(lineSeparator) 261 .append(this.requestMethod) 262 .append(' ') 263 .append(this.requestUrl) 264 .append(lineSeparator) 265 .append(contentType != null ? "Content-Type: " + contentType + lineSeparator : "") 266 .append(headers.isEmpty() ? "" : "Headers:" + lineSeparator); 267 headers.entrySet() 268 .stream() 269 .filter(Objects::nonNull) 270 .forEach(e -> builder.append(format("%s: [%s]%s", e.getKey().toLowerCase(), e.getValue(), lineSeparator))); 271 272 String bodyString = this.bodyToString(); 273 if (bodyString != null && !bodyString.equals("")) { 274 builder.append("Body:").append(lineSeparator).append(bodyString); 275 } 276 277 return builder.toString().trim(); 278 } 279 280 @Override 281 public void close() { 282 try { 283 if (this.inputStream == null && this.rawInputStream != null) { 284 this.rawInputStream.close(); 285 } 286 if (this.inputStream != null) { 287 this.inputStream.close(); 288 } 289 } catch (IOException e) { 290 throw new BoxAPIException( 291 "Couldn't finish closing the connection to the Box API due to a network error or " 292 + "because the stream was already closed.", e 293 ); 294 } 295 } 296 297 /** 298 * Returns a string representation of this response's body. This method is used when logging this response's body. 299 * By default, it returns an empty string (to avoid accidentally logging binary data) unless the response contained 300 * an error message. 301 * 302 * @return a string representation of this response's body. 303 */ 304 protected String bodyToString() { 305 return this.bodyString; 306 } 307 308 private void logResponse() { 309 if (LOGGER.isDebugEnabled()) { 310 LOGGER.debug(this.toString()); 311 } 312 } 313 314 private void logErrorResponse(int responseCode) { 315 if (responseCode < 500 && LOGGER.isWarnEnabled()) { 316 LOGGER.warn(this.toString()); 317 } 318 if (responseCode >= 500 && LOGGER.isErrorEnabled()) { 319 LOGGER.error(this.toString()); 320 } 321 } 322 323 protected String getRequestMethod() { 324 return requestMethod; 325 } 326 327 protected String getRequestUrl() { 328 return requestUrl; 329 } 330}