001package com.box.sdk; 002 003import com.box.sdk.http.ContentType; 004import com.box.sdk.http.HttpHeaders; 005import com.box.sdk.http.HttpMethod; 006import com.eclipsesource.json.Json; 007import com.eclipsesource.json.JsonArray; 008import com.eclipsesource.json.JsonObject; 009import com.eclipsesource.json.JsonValue; 010import java.io.ByteArrayInputStream; 011import java.io.IOException; 012import java.io.InputStream; 013import java.net.MalformedURLException; 014import java.net.URL; 015import java.security.MessageDigest; 016import java.security.NoSuchAlgorithmException; 017import java.text.ParseException; 018import java.util.Date; 019import java.util.List; 020import java.util.Map; 021 022/** 023 * This API provides a way to reliably upload larger files to Box by chunking them into a sequence 024 * of parts. When using this APIinstead of the single file upload API, a request failure means a 025 * client only needs to retry upload of a single part instead of the entire file. Parts can also be 026 * uploaded in parallel allowing for potential performance improvement. 027 */ 028@BoxResourceType("upload_session") 029public class BoxFileUploadSession extends BoxResource { 030 031 private static final String DIGEST_HEADER_PREFIX_SHA = "sha="; 032 private static final String DIGEST_ALGORITHM_SHA1 = "SHA1"; 033 034 private static final String OFFSET_QUERY_STRING = "offset"; 035 private static final String LIMIT_QUERY_STRING = "limit"; 036 037 private Info sessionInfo; 038 039 /** 040 * Constructs a BoxFileUploadSession for a file with a given ID. 041 * 042 * @param api the API connection to be used by the upload session. 043 * @param id the ID of the upload session. 044 */ 045 BoxFileUploadSession(BoxAPIConnection api, String id) { 046 super(api, id); 047 } 048 049 /** 050 * Uploads chunk of a stream to an open upload session. 051 * 052 * @param stream the stream that is used to read the chunck using the offset and part size. 053 * @param offset the byte position where the chunk begins in the file. 054 * @param partSize the part size returned as part of the upload session instance creation. Only 055 * the last chunk can have a lesser value. 056 * @param totalSizeOfFile The total size of the file being uploaded. 057 * @return the part instance that contains the part id, offset and part size. 058 */ 059 public BoxFileUploadSessionPart uploadPart( 060 InputStream stream, long offset, int partSize, long totalSizeOfFile) { 061 062 URL uploadPartURL = this.sessionInfo.getSessionEndpoints().getUploadPartEndpoint(); 063 064 BoxAPIRequest request = new BoxAPIRequest(this.getAPI(), uploadPartURL, HttpMethod.PUT); 065 request.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_OCTET_STREAM); 066 067 // Read the partSize bytes from the stream 068 byte[] bytes = new byte[partSize]; 069 try { 070 stream.read(bytes); 071 } catch (IOException ioe) { 072 throw new BoxAPIException("Reading data from stream failed.", ioe); 073 } 074 075 return this.uploadPart(bytes, offset, partSize, totalSizeOfFile); 076 } 077 078 /** 079 * Uploads bytes to an open upload session. 080 * 081 * @param data data 082 * @param offset the byte position where the chunk begins in the file. 083 * @param partSize the part size returned as part of the upload session instance creation. Only 084 * the last chunk can have a lesser value. 085 * @param totalSizeOfFile The total size of the file being uploaded. 086 * @return the part instance that contains the part id, offset and part size. 087 */ 088 public BoxFileUploadSessionPart uploadPart( 089 byte[] data, long offset, int partSize, long totalSizeOfFile) { 090 URL uploadPartURL = this.sessionInfo.getSessionEndpoints().getUploadPartEndpoint(); 091 092 BoxAPIRequest request = 093 new BoxAPIRequest( 094 this.getAPI(), 095 uploadPartURL, 096 HttpMethod.PUT.name(), 097 ContentType.APPLICATION_OCTET_STREAM); 098 099 MessageDigest digestInstance; 100 try { 101 digestInstance = MessageDigest.getInstance(DIGEST_ALGORITHM_SHA1); 102 } catch (NoSuchAlgorithmException ae) { 103 throw new BoxAPIException("Digest algorithm not found", ae); 104 } 105 106 // Creates the digest using SHA1 algorithm. Then encodes the bytes using Base64. 107 byte[] digestBytes = digestInstance.digest(data); 108 String digest = Base64.encode(digestBytes); 109 request.addHeader(HttpHeaders.DIGEST, DIGEST_HEADER_PREFIX_SHA + digest); 110 // Content-Range: bytes offset-part/totalSize 111 request.addHeader( 112 HttpHeaders.CONTENT_RANGE, 113 "bytes " + offset + "-" + (offset + partSize - 1) + "/" + totalSizeOfFile); 114 115 // Creates the body 116 request.setBody(new ByteArrayInputStream(data)); 117 return request.sendForUploadPart(this, offset); 118 } 119 120 /** 121 * Returns a list of all parts that have been uploaded to an upload session. 122 * 123 * @param offset paging marker for the list of parts. 124 * @param limit maximum number of parts to return. 125 * @return the list of parts. 126 */ 127 public BoxFileUploadSessionPartList listParts(int offset, int limit) { 128 URL listPartsURL = this.sessionInfo.getSessionEndpoints().getListPartsEndpoint(); 129 URLTemplate template = new URLTemplate(listPartsURL.toString()); 130 131 QueryStringBuilder builder = new QueryStringBuilder(); 132 builder.appendParam(OFFSET_QUERY_STRING, offset); 133 String queryString = builder.appendParam(LIMIT_QUERY_STRING, limit).toString(); 134 135 // Template is initalized with the full URL. So empty string for the path. 136 URL url = template.buildWithQuery("", queryString); 137 138 BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), url, HttpMethod.GET); 139 try (BoxJSONResponse response = request.send()) { 140 JsonObject jsonObject = Json.parse(response.getJSON()).asObject(); 141 142 return new BoxFileUploadSessionPartList(jsonObject); 143 } 144 } 145 146 /** 147 * Returns a list of all parts that have been uploaded to an upload session. 148 * 149 * @return the list of parts. 150 */ 151 protected Iterable<BoxFileUploadSessionPart> listParts() { 152 URL listPartsURL = this.sessionInfo.getSessionEndpoints().getListPartsEndpoint(); 153 int limit = 100; 154 return new BoxResourceIterable<BoxFileUploadSessionPart>(this.getAPI(), listPartsURL, limit) { 155 156 @Override 157 protected BoxFileUploadSessionPart factory(JsonObject jsonObject) { 158 return new BoxFileUploadSessionPart(jsonObject); 159 } 160 }; 161 } 162 163 /** 164 * Commit an upload session after all parts have been uploaded, creating the new file or the 165 * version. 166 * 167 * @param digest the base64-encoded SHA-1 hash of the file being uploaded. 168 * @param parts the list of uploaded parts to be committed. 169 * @param attributes the key value pairs of attributes from the file instance. 170 * @param ifMatch ensures that your app only alters files/folders on Box if you have the current 171 * version. 172 * @param ifNoneMatch ensure that it retrieve unnecessary data if the most current version of file 173 * is on-hand. 174 * @return the created file instance. 175 */ 176 public BoxFile.Info commit( 177 String digest, 178 List<BoxFileUploadSessionPart> parts, 179 Map<String, String> attributes, 180 String ifMatch, 181 String ifNoneMatch) { 182 183 URL commitURL = this.sessionInfo.getSessionEndpoints().getCommitEndpoint(); 184 BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), commitURL, HttpMethod.POST); 185 request.addHeader(HttpHeaders.DIGEST, DIGEST_HEADER_PREFIX_SHA + digest); 186 request.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON); 187 188 if (ifMatch != null) { 189 request.addHeader(HttpHeaders.IF_MATCH, ifMatch); 190 } 191 192 if (ifNoneMatch != null) { 193 request.addHeader(HttpHeaders.IF_NONE_MATCH, ifNoneMatch); 194 } 195 196 // Creates the body of the request 197 String body = this.getCommitBody(parts, attributes); 198 request.setBody(body); 199 200 try (BoxJSONResponse response = request.send()) { 201 // Retry the commit operation after the given number of seconds if the HTTP response code is 202 // 202. 203 if (response.getResponseCode() == 202) { 204 String retryInterval = response.getHeaderField("retry-after"); 205 if (retryInterval != null) { 206 try { 207 Thread.sleep(new Integer(retryInterval) * 1000); 208 } catch (InterruptedException ie) { 209 throw new BoxAPIException("Commit retry failed. ", ie); 210 } 211 212 return this.commit(digest, parts, attributes, ifMatch, ifNoneMatch); 213 } 214 } 215 216 // Create the file instance from the response 217 return this.getFile(response); 218 } 219 } 220 221 /* 222 * Creates the file isntance from the JSON body of the response. 223 */ 224 private BoxFile.Info getFile(BoxJSONResponse response) { 225 JsonObject jsonObject = Json.parse(response.getJSON()).asObject(); 226 227 JsonArray array = (JsonArray) jsonObject.get("entries"); 228 JsonObject fileObj = (JsonObject) array.get(0); 229 230 BoxFile file = new BoxFile(this.getAPI(), fileObj.get("id").asString()); 231 232 return file.new Info(fileObj); 233 } 234 235 /* 236 * Creates the JSON body for the commit request. 237 */ 238 private String getCommitBody( 239 List<BoxFileUploadSessionPart> parts, Map<String, String> attributes) { 240 JsonObject jsonObject = new JsonObject(); 241 242 JsonArray array = new JsonArray(); 243 for (BoxFileUploadSessionPart part : parts) { 244 JsonObject partObj = new JsonObject(); 245 partObj.add("part_id", part.getPartId()); 246 partObj.add("offset", part.getOffset()); 247 partObj.add("size", part.getSize()); 248 249 array.add(partObj); 250 } 251 jsonObject.add("parts", array); 252 253 if (attributes != null) { 254 JsonObject attrObj = new JsonObject(); 255 for (String key : attributes.keySet()) { 256 attrObj.add(key, attributes.get(key)); 257 } 258 jsonObject.add("attributes", attrObj); 259 } 260 261 return jsonObject.toString(); 262 } 263 264 /** 265 * Get the status of the upload session. It contains the number of parts that are processed so 266 * far, the total number of parts required for the commit and expiration date and time of the 267 * upload session. 268 * 269 * @return the status. 270 */ 271 public BoxFileUploadSession.Info getStatus() { 272 URL statusURL = this.sessionInfo.getSessionEndpoints().getStatusEndpoint(); 273 BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), statusURL, HttpMethod.GET); 274 try (BoxJSONResponse response = request.send()) { 275 JsonObject jsonObject = Json.parse(response.getJSON()).asObject(); 276 277 this.sessionInfo.update(jsonObject); 278 279 return this.sessionInfo; 280 } 281 } 282 283 /** Abort an upload session, discarding any chunks that were uploaded to it. */ 284 public void abort() { 285 URL abortURL = this.sessionInfo.getSessionEndpoints().getAbortEndpoint(); 286 BoxAPIRequest request = new BoxAPIRequest(this.getAPI(), abortURL, HttpMethod.DELETE); 287 request.send().close(); 288 } 289 290 /** Model contains the upload session information. */ 291 public class Info extends BoxResource.Info { 292 293 private Date sessionExpiresAt; 294 private String uploadSessionId; 295 private Endpoints sessionEndpoints; 296 private int partSize; 297 private int totalParts; 298 private int partsProcessed; 299 300 /** 301 * Constructs an Info object by parsing information from a JSON string. 302 * 303 * @param json the JSON string to parse. 304 */ 305 public Info(String json) { 306 this(Json.parse(json).asObject()); 307 } 308 309 /** 310 * Constructs an Info object using an already parsed JSON object. 311 * 312 * @param jsonObject the parsed JSON object. 313 */ 314 Info(JsonObject jsonObject) { 315 super(jsonObject); 316 BoxFileUploadSession.this.sessionInfo = this; 317 } 318 319 /** 320 * Returns the BoxFileUploadSession isntance to which this object belongs to. 321 * 322 * @return the instance of upload session. 323 */ 324 public BoxFileUploadSession getResource() { 325 return BoxFileUploadSession.this; 326 } 327 328 /** 329 * Returns the total parts of the file that is uploaded in the upload session. 330 * 331 * @return the total number of parts. 332 */ 333 public int getTotalParts() { 334 return this.totalParts; 335 } 336 337 /** 338 * Returns the parts that are processed so for. 339 * 340 * @return the number of the processed parts. 341 */ 342 public int getPartsProcessed() { 343 return this.partsProcessed; 344 } 345 346 /** 347 * Returns the date and time at which the upload session expires. 348 * 349 * @return the date and time in UTC format. 350 */ 351 public Date getSessionExpiresAt() { 352 return this.sessionExpiresAt; 353 } 354 355 /** 356 * Returns the upload session id. 357 * 358 * @return the id string. 359 */ 360 public String getUploadSessionId() { 361 return this.uploadSessionId; 362 } 363 364 /** 365 * Returns the session endpoints that can be called for this upload session. 366 * 367 * @return the Endpoints instance. 368 */ 369 public Endpoints getSessionEndpoints() { 370 return this.sessionEndpoints; 371 } 372 373 /** 374 * Returns the size of the each part. Only the last part of the file can be lessor than this 375 * value. 376 * 377 * @return the part size. 378 */ 379 public int getPartSize() { 380 return this.partSize; 381 } 382 383 @Override 384 protected void parseJSONMember(JsonObject.Member member) { 385 386 String memberName = member.getName(); 387 JsonValue value = member.getValue(); 388 if (memberName.equals("session_expires_at")) { 389 try { 390 String dateStr = value.asString(); 391 this.sessionExpiresAt = 392 BoxDateFormat.parse(dateStr.substring(0, dateStr.length() - 1) + "-00:00"); 393 } catch (ParseException pe) { 394 assert false : "A ParseException indicates a bug in the SDK."; 395 } 396 } else if (memberName.equals("id")) { 397 this.uploadSessionId = value.asString(); 398 } else if (memberName.equals("part_size")) { 399 this.partSize = Integer.parseInt(value.toString()); 400 } else if (memberName.equals("session_endpoints")) { 401 this.sessionEndpoints = new Endpoints(value.asObject()); 402 } else if (memberName.equals("total_parts")) { 403 this.totalParts = value.asInt(); 404 } else if (memberName.equals("num_parts_processed")) { 405 this.partsProcessed = value.asInt(); 406 } 407 } 408 } 409 410 /** Represents the end points specific to an upload session. */ 411 public static class Endpoints extends BoxJSONObject { 412 private URL listPartsEndpoint; 413 private URL commitEndpoint; 414 private URL uploadPartEndpoint; 415 private URL statusEndpoint; 416 private URL abortEndpoint; 417 418 /** 419 * Constructs an Endpoints object using an already parsed JSON object. 420 * 421 * @param jsonObject the parsed JSON object. 422 */ 423 Endpoints(JsonObject jsonObject) { 424 super(jsonObject); 425 } 426 427 /** 428 * Returns the list parts end point. 429 * 430 * @return the url of the list parts end point. 431 */ 432 public URL getListPartsEndpoint() { 433 return this.listPartsEndpoint; 434 } 435 436 /** 437 * Returns the commit end point. 438 * 439 * @return the url of the commit end point. 440 */ 441 public URL getCommitEndpoint() { 442 return this.commitEndpoint; 443 } 444 445 /** 446 * Returns the upload part end point. 447 * 448 * @return the url of the upload part end point. 449 */ 450 public URL getUploadPartEndpoint() { 451 return this.uploadPartEndpoint; 452 } 453 454 /** 455 * Returns the upload session status end point. 456 * 457 * @return the url of the session end point. 458 */ 459 public URL getStatusEndpoint() { 460 return this.statusEndpoint; 461 } 462 463 /** 464 * Returns the abort upload session end point. 465 * 466 * @return the url of the abort end point. 467 */ 468 public URL getAbortEndpoint() { 469 return this.abortEndpoint; 470 } 471 472 @Override 473 protected void parseJSONMember(JsonObject.Member member) { 474 475 String memberName = member.getName(); 476 JsonValue value = member.getValue(); 477 try { 478 if (memberName.equals("list_parts")) { 479 this.listPartsEndpoint = new URL(value.asString()); 480 } else if (memberName.equals("commit")) { 481 this.commitEndpoint = new URL(value.asString()); 482 } else if (memberName.equals("upload_part")) { 483 this.uploadPartEndpoint = new URL(value.asString()); 484 } else if (memberName.equals("status")) { 485 this.statusEndpoint = new URL(value.asString()); 486 } else if (memberName.equals("abort")) { 487 this.abortEndpoint = new URL(value.asString()); 488 } 489 } catch (MalformedURLException mue) { 490 assert false : "A ParseException indicates a bug in the SDK."; 491 } 492 } 493 } 494}