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( 093 this.getAPI(), uploadPartURL, HttpMethod.PUT.name(), ContentType.APPLICATION_OCTET_STREAM 094 ); 095 096 MessageDigest digestInstance; 097 try { 098 digestInstance = MessageDigest.getInstance(DIGEST_ALGORITHM_SHA1); 099 } catch (NoSuchAlgorithmException ae) { 100 throw new BoxAPIException("Digest algorithm not found", ae); 101 } 102 103 //Creates the digest using SHA1 algorithm. Then encodes the bytes using Base64. 104 byte[] digestBytes = digestInstance.digest(data); 105 String digest = Base64.encode(digestBytes); 106 request.addHeader(HttpHeaders.DIGEST, DIGEST_HEADER_PREFIX_SHA + digest); 107 //Content-Range: bytes offset-part/totalSize 108 request.addHeader(HttpHeaders.CONTENT_RANGE, 109 "bytes " + offset + "-" + (offset + partSize - 1) + "/" + totalSizeOfFile); 110 111 //Creates the body 112 request.setBody(new ByteArrayInputStream(data)); 113 return request.sendForUploadPart(this, offset); 114 } 115 116 /** 117 * Returns a list of all parts that have been uploaded to an upload session. 118 * 119 * @param offset paging marker for the list of parts. 120 * @param limit maximum number of parts to return. 121 * @return the list of parts. 122 */ 123 public BoxFileUploadSessionPartList listParts(int offset, int limit) { 124 URL listPartsURL = this.sessionInfo.getSessionEndpoints().getListPartsEndpoint(); 125 URLTemplate template = new URLTemplate(listPartsURL.toString()); 126 127 QueryStringBuilder builder = new QueryStringBuilder(); 128 builder.appendParam(OFFSET_QUERY_STRING, offset); 129 String queryString = builder.appendParam(LIMIT_QUERY_STRING, limit).toString(); 130 131 //Template is initalized with the full URL. So empty string for the path. 132 URL url = template.buildWithQuery("", queryString); 133 134 BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), url, HttpMethod.GET); 135 try (BoxJSONResponse response = request.send()) { 136 JsonObject jsonObject = Json.parse(response.getJSON()).asObject(); 137 138 return new BoxFileUploadSessionPartList(jsonObject); 139 } 140 } 141 142 /** 143 * Returns a list of all parts that have been uploaded to an upload session. 144 * 145 * @return the list of parts. 146 */ 147 protected Iterable<BoxFileUploadSessionPart> listParts() { 148 URL listPartsURL = this.sessionInfo.getSessionEndpoints().getListPartsEndpoint(); 149 int limit = 100; 150 return new BoxResourceIterable<BoxFileUploadSessionPart>( 151 this.getAPI(), 152 listPartsURL, 153 limit) { 154 155 @Override 156 protected BoxFileUploadSessionPart factory(JsonObject jsonObject) { 157 return new BoxFileUploadSessionPart(jsonObject); 158 } 159 }; 160 } 161 162 /** 163 * Commit an upload session after all parts have been uploaded, creating the new file or the version. 164 * 165 * @param digest the base64-encoded SHA-1 hash of the file being uploaded. 166 * @param parts the list of uploaded parts to be committed. 167 * @param attributes the key value pairs of attributes from the file instance. 168 * @param ifMatch ensures that your app only alters files/folders on Box if you have the current version. 169 * @param ifNoneMatch ensure that it retrieve unnecessary data if the most current version of file is on-hand. 170 * @return the created file instance. 171 */ 172 public BoxFile.Info commit(String digest, List<BoxFileUploadSessionPart> parts, 173 Map<String, String> attributes, String ifMatch, String ifNoneMatch) { 174 175 URL commitURL = this.sessionInfo.getSessionEndpoints().getCommitEndpoint(); 176 BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), commitURL, HttpMethod.POST); 177 request.addHeader(HttpHeaders.DIGEST, DIGEST_HEADER_PREFIX_SHA + digest); 178 request.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON); 179 180 if (ifMatch != null) { 181 request.addHeader(HttpHeaders.IF_MATCH, ifMatch); 182 } 183 184 if (ifNoneMatch != null) { 185 request.addHeader(HttpHeaders.IF_NONE_MATCH, ifNoneMatch); 186 } 187 188 //Creates the body of the request 189 String body = this.getCommitBody(parts, attributes); 190 request.setBody(body); 191 192 try (BoxJSONResponse response = request.send()) { 193 //Retry the commit operation after the given number of seconds if the HTTP response code is 202. 194 if (response.getResponseCode() == 202) { 195 String retryInterval = response.getHeaderField("retry-after"); 196 if (retryInterval != null) { 197 try { 198 Thread.sleep(new Integer(retryInterval) * 1000); 199 } catch (InterruptedException ie) { 200 throw new BoxAPIException("Commit retry failed. ", ie); 201 } 202 203 return this.commit(digest, parts, attributes, ifMatch, ifNoneMatch); 204 } 205 } 206 207 //Create the file instance from the response 208 return this.getFile(response); 209 } 210 } 211 212 /* 213 * Creates the file isntance from the JSON body of the response. 214 */ 215 private BoxFile.Info getFile(BoxJSONResponse response) { 216 JsonObject jsonObject = Json.parse(response.getJSON()).asObject(); 217 218 JsonArray array = (JsonArray) jsonObject.get("entries"); 219 JsonObject fileObj = (JsonObject) array.get(0); 220 221 BoxFile file = new BoxFile(this.getAPI(), fileObj.get("id").asString()); 222 223 return file.new Info(fileObj); 224 } 225 226 /* 227 * Creates the JSON body for the commit request. 228 */ 229 private String getCommitBody(List<BoxFileUploadSessionPart> parts, Map<String, String> attributes) { 230 JsonObject jsonObject = new JsonObject(); 231 232 JsonArray array = new JsonArray(); 233 for (BoxFileUploadSessionPart part : parts) { 234 JsonObject partObj = new JsonObject(); 235 partObj.add("part_id", part.getPartId()); 236 partObj.add("offset", part.getOffset()); 237 partObj.add("size", part.getSize()); 238 239 array.add(partObj); 240 } 241 jsonObject.add("parts", array); 242 243 if (attributes != null) { 244 JsonObject attrObj = new JsonObject(); 245 for (String key : attributes.keySet()) { 246 attrObj.add(key, attributes.get(key)); 247 } 248 jsonObject.add("attributes", attrObj); 249 } 250 251 return jsonObject.toString(); 252 } 253 254 /** 255 * Get the status of the upload session. It contains the number of parts that are processed so far, 256 * the total number of parts required for the commit and expiration date and time of the upload session. 257 * 258 * @return the status. 259 */ 260 public BoxFileUploadSession.Info getStatus() { 261 URL statusURL = this.sessionInfo.getSessionEndpoints().getStatusEndpoint(); 262 BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), statusURL, HttpMethod.GET); 263 try (BoxJSONResponse response = request.send()) { 264 JsonObject jsonObject = Json.parse(response.getJSON()).asObject(); 265 266 this.sessionInfo.update(jsonObject); 267 268 return this.sessionInfo; 269 } 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 BoxAPIRequest request = new BoxAPIRequest(this.getAPI(), abortURL, HttpMethod.DELETE); 278 request.send().close(); 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(Json.parse(json).asObject()); 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.parseInt(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 static 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}