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 BoxJSONResponse response = (BoxJSONResponse) request.send(); 294 JsonObject jsonObject = JsonObject.readFrom(response.getJSON()); 295 BoxFileUploadSessionPart part = new BoxFileUploadSessionPart((JsonObject) jsonObject.get("part")); 296 return part; 297 } 298 299 /** 300 * Returns a list of all parts that have been uploaded to an upload session. 301 * @param offset paging marker for the list of parts. 302 * @param limit maximum number of parts to return. 303 * @return the list of parts. 304 */ 305 public BoxFileUploadSessionPartList listParts(int offset, int limit) { 306 URL listPartsURL = this.sessionInfo.getSessionEndpoints().getListPartsEndpoint(); 307 URLTemplate template = new URLTemplate(listPartsURL.toString()); 308 309 QueryStringBuilder builder = new QueryStringBuilder(); 310 builder.appendParam(OFFSET_QUERY_STRING, offset); 311 String queryString = builder.appendParam(LIMIT_QUERY_STRING, limit).toString(); 312 313 //Template is initalized with the full URL. So empty string for the path. 314 URL url = template.buildWithQuery("", queryString); 315 316 BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), url, HttpMethod.GET); 317 BoxJSONResponse response = (BoxJSONResponse) request.send(); 318 JsonObject jsonObject = JsonObject.readFrom(response.getJSON()); 319 320 return new BoxFileUploadSessionPartList(jsonObject); 321 } 322 323 /** 324 * Commit an upload session after all parts have been uploaded, creating the new file or the version. 325 * @param digest the base64-encoded SHA-1 hash of the file being uploaded. 326 * @param parts the list of uploaded parts to be committed. 327 * @param attributes the key value pairs of attributes from the file instance. 328 * @param ifMatch ensures that your app only alters files/folders on Box if you have the current version. 329 * @param ifNoneMatch ensure that it retrieve unnecessary data if the most current version of file is on-hand. 330 * @return the created file instance. 331 */ 332 public BoxFile.Info commit(String digest, List<BoxFileUploadSessionPart> parts, 333 Map<String, String> attributes, String ifMatch, String ifNoneMatch) { 334 335 URL commitURL = this.sessionInfo.getSessionEndpoints().getCommitEndpoint(); 336 BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), commitURL, HttpMethod.POST); 337 request.addHeader(HttpHeaders.DIGEST, DIGEST_HEADER_PREFIX_SHA + digest); 338 request.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON); 339 340 if (ifMatch != null) { 341 request.addHeader(HttpHeaders.IF_MATCH, ifMatch); 342 } 343 344 if (ifNoneMatch != null) { 345 request.addHeader(HttpHeaders.IF_NONE_MATCH, ifNoneMatch); 346 } 347 348 //Creates the body of the request 349 String body = this.getCommitBody(parts, attributes); 350 request.setBody(body); 351 352 BoxAPIResponse response = request.send(); 353 //Retry the commit operation after the given number of seconds if the HTTP response code is 202. 354 if (response.getResponseCode() == 202) { 355 String retryInterval = response.getHeaderField("retry-after"); 356 if (retryInterval != null) { 357 try { 358 Thread.sleep(new Integer(retryInterval) * 1000); 359 } catch (InterruptedException ie) { 360 throw new BoxAPIException("Commit retry failed. ", ie); 361 } 362 363 return this.commit(digest, parts, attributes, ifMatch, ifNoneMatch); 364 } 365 } 366 367 if (response instanceof BoxJSONResponse) { 368 //Create the file instance from the response 369 return this.getFile((BoxJSONResponse) response); 370 } else { 371 throw new BoxAPIException("Commit response content type is not application/json. The response code : " 372 + response.getResponseCode()); 373 } 374 } 375 376 /* 377 * Creates the file isntance from the JSON body of the response. 378 */ 379 private BoxFile.Info getFile(BoxJSONResponse response) { 380 JsonObject jsonObject = JsonObject.readFrom(response.getJSON()); 381 382 JsonArray array = (JsonArray) jsonObject.get("entries"); 383 JsonObject fileObj = (JsonObject) array.get(0); 384 385 BoxFile file = new BoxFile(this.getAPI(), fileObj.get("id").asString()); 386 387 return file.new Info(fileObj); 388 } 389 390 /* 391 * Creates the JSON body for the commit request. 392 */ 393 private String getCommitBody(List<BoxFileUploadSessionPart> parts, Map<String, String> attributes) { 394 JsonObject jsonObject = new JsonObject(); 395 396 JsonArray array = new JsonArray(); 397 for (BoxFileUploadSessionPart part: parts) { 398 JsonObject partObj = new JsonObject(); 399 partObj.add("part_id", part.getPartId()); 400 partObj.add("offset", part.getOffset()); 401 partObj.add("size", part.getSize()); 402 403 array.add(partObj); 404 } 405 jsonObject.add("parts", array); 406 407 if (attributes != null) { 408 JsonObject attrObj = new JsonObject(); 409 for (String key: attributes.keySet()) { 410 attrObj.add(key, attributes.get(key)); 411 } 412 jsonObject.add("attributes", attrObj); 413 } 414 415 return jsonObject.toString(); 416 } 417 418 /** 419 * Get the status of the upload session. It contains the number of parts that are processed so far, 420 * the total number of parts required for the commit and expiration date and time of the upload session. 421 * @return the status. 422 */ 423 public BoxFileUploadSession.Info getStatus() { 424 URL statusURL = this.sessionInfo.getSessionEndpoints().getStatusEndpoint(); 425 BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), statusURL, HttpMethod.GET); 426 BoxJSONResponse response = (BoxJSONResponse) request.send(); 427 JsonObject jsonObject = JsonObject.readFrom(response.getJSON()); 428 429 this.sessionInfo.update(jsonObject); 430 431 return this.sessionInfo; 432 } 433 434 /** 435 * Abort an upload session, discarding any chunks that were uploaded to it. 436 */ 437 public void abort() { 438 URL abortURL = this.sessionInfo.getSessionEndpoints().getAbortEndpoint(); 439 BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), abortURL, HttpMethod.DELETE); 440 request.send(); 441 } 442}