001package com.box.sdk; 002 003import java.io.ByteArrayInputStream; 004import java.io.IOException; 005import java.io.InputStream; 006import java.io.OutputStream; 007import java.net.HttpURLConnection; 008import java.net.ProtocolException; 009import java.net.URL; 010import java.nio.charset.StandardCharsets; 011import java.util.ArrayList; 012import java.util.List; 013import java.util.Map; 014import java.util.logging.Level; 015import java.util.logging.Logger; 016 017/** 018 * Used to make HTTP requests to the Box API. 019 * 020 * <p>All requests to the REST API are sent using this class or one of its subclasses. This class wraps {@link 021 * HttpURLConnection} in order to provide a simpler interface that can automatically handle various conditions specific 022 * to Box's API. Requests will be authenticated using a {@link BoxAPIConnection} (if one is provided), so it isn't 023 * necessary to add authorization headers. Requests can also be sent more than once, unlike with HttpURLConnection. If 024 * an error occurs while sending a request, it will be automatically retried (with a back off delay) up to the maximum 025 * number of times set in the BoxAPIConnection.</p> 026 * 027 * <p>Specifying a body for a BoxAPIRequest is done differently than it is with HttpURLConnection. Instead of writing to 028 * an OutputStream, the request is provided an {@link InputStream} which will be read when the {@link #send} method is 029 * called. This makes it easy to retry requests since the stream can automatically reset and reread with each attempt. 030 * If the stream cannot be reset, then a new stream will need to be provided before each call to send. There is also a 031 * convenience method for specifying the body as a String, which simply wraps the String with an InputStream.</p> 032 */ 033public class BoxAPIRequest { 034 private static final Logger LOGGER = Logger.getLogger(BoxAPIRequest.class.getName()); 035 036 private final BoxAPIConnection api; 037 private final URL url; 038 private final List<RequestHeader> headers; 039 private final String method; 040 041 private BackoffCounter backoffCounter; 042 private int timeout; 043 private InputStream body; 044 private long bodyLength; 045 private Map<String, List<String>> requestProperties; 046 047 /** 048 * Constructs an unauthenticated BoxAPIRequest. 049 * @param url the URL of the request. 050 * @param method the HTTP method of the request. 051 */ 052 public BoxAPIRequest(URL url, String method) { 053 this(null, url, method); 054 } 055 056 /** 057 * Constructs an authenticated BoxAPIRequest using a provided BoxAPIConnection. 058 * @param api an API connection for authenticating the request. 059 * @param url the URL of the request. 060 * @param method the HTTP method of the request. 061 */ 062 public BoxAPIRequest(BoxAPIConnection api, URL url, String method) { 063 this.api = api; 064 this.url = url; 065 this.method = method; 066 this.headers = new ArrayList<RequestHeader>(); 067 this.backoffCounter = new BackoffCounter(new Time()); 068 069 this.addHeader("Accept-Encoding", "gzip"); 070 this.addHeader("Accept-Charset", "utf-8"); 071 } 072 073 /** 074 * Adds an HTTP header to this request. 075 * @param key the header key. 076 * @param value the header value. 077 */ 078 public void addHeader(String key, String value) { 079 this.headers.add(new RequestHeader(key, value)); 080 } 081 082 /** 083 * Sets a timeout for this request in milliseconds. 084 * @param timeout the timeout in milliseconds. 085 */ 086 public void setTimeout(int timeout) { 087 this.timeout = timeout; 088 } 089 090 /** 091 * Sets the request body to the contents of an InputStream. 092 * 093 * <p>The stream must support the {@link InputStream#reset} method if auto-retry is used or if the request needs to 094 * be resent. Otherwise, the body must be manually set before each call to {@link #send}.</p> 095 * 096 * @param stream an InputStream containing the contents of the body. 097 */ 098 public void setBody(InputStream stream) { 099 this.body = stream; 100 } 101 102 /** 103 * Sets the request body to the contents of an InputStream. 104 * 105 * <p>Providing the length of the InputStream allows for the progress of the request to be monitored when calling 106 * {@link #send(ProgressListener)}.</p> 107 * 108 * <p> See {@link #setBody(InputStream)} for more information on setting the body of the request.</p> 109 * 110 * @param stream an InputStream containing the contents of the body. 111 * @param length the expected length of the stream. 112 */ 113 public void setBody(InputStream stream, long length) { 114 this.bodyLength = length; 115 this.body = stream; 116 } 117 118 /** 119 * Sets the request body to the contents of a String. 120 * 121 * <p>If the contents of the body are large, then it may be more efficient to use an {@link InputStream} instead of 122 * a String. Using a String requires that the entire body be in memory before sending the request.</p> 123 * 124 * @param body a String containing the contents of the body. 125 */ 126 public void setBody(String body) { 127 this.bodyLength = body.length(); 128 this.body = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)); 129 } 130 131 /** 132 * Sends this request and returns a BoxAPIResponse containing the server's response. 133 * 134 * <p>The type of the returned BoxAPIResponse will be based on the content type returned by the server, allowing it 135 * to be cast to a more specific type. For example, if it's known that the API call will return a JSON response, 136 * then it can be cast to a {@link BoxJSONResponse} like so:</p> 137 * 138 * <pre>BoxJSONResponse response = (BoxJSONResponse) request.send();</pre> 139 * 140 * <p>If the server returns an error code or if a network error occurs, then the request will be automatically 141 * retried. If the maximum number of retries is reached and an error still occurs, then a {@link BoxAPIException} 142 * will be thrown.</p> 143 * 144 * @throws BoxAPIException if the server returns an error code or if a network error occurs. 145 * @return a {@link BoxAPIResponse} containing the server's response. 146 */ 147 public BoxAPIResponse send() { 148 return this.send(null); 149 } 150 151 /** 152 * Sends this request while monitoring its progress and returns a BoxAPIResponse containing the server's response. 153 * 154 * <p>A ProgressListener is generally only useful when the size of the request is known beforehand. If the size is 155 * unknown, then the ProgressListener will be updated for each byte sent, but the total number of bytes will be 156 * reported as 0.<p> 157 * 158 * <p> See {@link #send} for more information on sending requests.</p> 159 * 160 * @param listener a listener for monitoring the progress of the request. 161 * @throws BoxAPIException if the server returns an error code or if a network error occurs. 162 * @return a {@link BoxAPIResponse} containing the server's response. 163 */ 164 public BoxAPIResponse send(ProgressListener listener) { 165 if (this.api == null) { 166 this.backoffCounter.reset(BoxAPIConnection.DEFAULT_MAX_ATTEMPTS); 167 } else { 168 this.backoffCounter.reset(this.api.getMaxRequestAttempts()); 169 } 170 171 while (this.backoffCounter.getAttemptsRemaining() > 0) { 172 try { 173 return this.trySend(listener); 174 } catch (BoxAPIException apiException) { 175 if (!this.backoffCounter.decrement() || !isResponseRetryable(apiException.getResponseCode())) { 176 throw apiException; 177 } 178 179 try { 180 this.resetBody(); 181 } catch (IOException ioException) { 182 throw apiException; 183 } 184 185 try { 186 this.backoffCounter.waitBackoff(); 187 } catch (InterruptedException interruptedException) { 188 Thread.currentThread().interrupt(); 189 throw apiException; 190 } 191 } 192 } 193 194 throw new RuntimeException(); 195 } 196 197 /** 198 * Returns a String containing the URL, HTTP method, headers and body of this request. 199 * @return a String containing information about this request. 200 */ 201 @Override 202 public String toString() { 203 StringBuilder builder = new StringBuilder(); 204 builder.append("Request"); 205 builder.append(System.lineSeparator()); 206 builder.append(this.method); 207 builder.append(' '); 208 builder.append(this.url.toString()); 209 builder.append(System.lineSeparator()); 210 211 for (Map.Entry<String, List<String>> entry : this.requestProperties.entrySet()) { 212 List<String> nonEmptyValues = new ArrayList<String>(); 213 for (String value : entry.getValue()) { 214 if (value != null && value.trim().length() != 0) { 215 nonEmptyValues.add(value); 216 } 217 } 218 219 if (nonEmptyValues.size() == 0) { 220 continue; 221 } 222 223 builder.append(entry.getKey()); 224 builder.append(": "); 225 for (String value : nonEmptyValues) { 226 builder.append(value); 227 builder.append(", "); 228 } 229 230 builder.delete(builder.length() - 2, builder.length()); 231 builder.append(System.lineSeparator()); 232 } 233 234 String bodyString = this.bodyToString(); 235 if (bodyString != null) { 236 builder.append(System.lineSeparator()); 237 builder.append(bodyString); 238 } 239 240 return builder.toString().trim(); 241 } 242 243 /** 244 * Returns a String representation of this request's body used in {@link #toString}. This method returns 245 * null by default. 246 * 247 * <p>A subclass may want override this method if the body can be converted to a String for logging or debugging 248 * purposes.</p> 249 * 250 * @return a String representation of this request's body. 251 */ 252 protected String bodyToString() { 253 return null; 254 } 255 256 /** 257 * Writes the body of this request to an HttpURLConnection. 258 * 259 * <p>Subclasses overriding this method must remember to close the connection's OutputStream after writing.</p> 260 * 261 * @param connection the connection to which the body should be written. 262 * @param listener an optional listener for monitoring the write progress. 263 * @throws BoxAPIException if an error occurs while writing to the connection. 264 */ 265 protected void writeBody(HttpURLConnection connection, ProgressListener listener) { 266 if (this.body == null) { 267 return; 268 } 269 270 connection.setDoOutput(true); 271 try { 272 OutputStream output = connection.getOutputStream(); 273 if (listener != null) { 274 output = new ProgressOutputStream(output, listener, this.bodyLength); 275 } 276 int b = this.body.read(); 277 while (b != -1) { 278 output.write(b); 279 b = this.body.read(); 280 } 281 output.close(); 282 } catch (IOException e) { 283 throw new BoxAPIException("Couldn't connect to the Box API due to a network error.", e); 284 } 285 } 286 287 /** 288 * Resets the InputStream containing this request's body. 289 * 290 * <p>This method will be called before each attempt to resend the request, giving subclasses an opportunity to 291 * reset any streams that need to be read when sending the body.</p> 292 * 293 * @throws IOException if the stream cannot be reset. 294 */ 295 protected void resetBody() throws IOException { 296 if (this.body != null) { 297 this.body.reset(); 298 } 299 } 300 301 void setBackoffCounter(BackoffCounter counter) { 302 this.backoffCounter = counter; 303 } 304 305 private BoxAPIResponse trySend(ProgressListener listener) { 306 HttpURLConnection connection = this.createConnection(); 307 connection.setRequestProperty("User-Agent", "Box Java SDK v0.4"); 308 309 if (this.bodyLength > 0) { 310 connection.setFixedLengthStreamingMode(this.bodyLength); 311 connection.setDoOutput(true); 312 } 313 314 if (this.api != null) { 315 connection.addRequestProperty("Authorization", "Bearer " + this.api.getAccessToken()); 316 } 317 318 this.requestProperties = connection.getRequestProperties(); 319 this.writeBody(connection, listener); 320 321 // Ensure that we're connected in case writeBody() didn't write anything. 322 try { 323 connection.connect(); 324 } catch (IOException e) { 325 throw new BoxAPIException("Couldn't connect to the Box API due to a network error.", e); 326 } 327 328 this.logRequest(connection); 329 330 String contentType = connection.getContentType(); 331 BoxAPIResponse response; 332 if (contentType == null) { 333 response = new BoxAPIResponse(connection); 334 } else if (contentType.contains("application/json")) { 335 response = new BoxJSONResponse(connection); 336 } else { 337 response = new BoxAPIResponse(connection); 338 } 339 340 return response; 341 } 342 343 private void logRequest(HttpURLConnection connection) { 344 if (LOGGER.isLoggable(Level.FINE)) { 345 LOGGER.log(Level.FINE, this.toString()); 346 } 347 } 348 349 private HttpURLConnection createConnection() { 350 HttpURLConnection connection = null; 351 352 try { 353 connection = (HttpURLConnection) this.url.openConnection(); 354 } catch (IOException e) { 355 throw new BoxAPIException("Couldn't connect to the Box API due to a network error.", e); 356 } 357 358 try { 359 connection.setRequestMethod(this.method); 360 } catch (ProtocolException e) { 361 throw new BoxAPIException("Couldn't connect to the Box API because the request's method was invalid.", e); 362 } 363 364 connection.setConnectTimeout(this.timeout); 365 connection.setReadTimeout(this.timeout); 366 367 for (RequestHeader header : this.headers) { 368 connection.addRequestProperty(header.getKey(), header.getValue()); 369 } 370 371 return connection; 372 } 373 374 private static boolean isResponseRetryable(int responseCode) { 375 return (responseCode >= 500 || responseCode == 429); 376 } 377 378 private final class RequestHeader { 379 private final String key; 380 private final String value; 381 382 public RequestHeader(String key, String value) { 383 this.key = key; 384 this.value = value; 385 } 386 387 public String getKey() { 388 return this.key; 389 } 390 391 public String getValue() { 392 return this.value; 393 } 394 } 395}