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