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