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