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