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