package com.box.sdk; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.net.MalformedURLException; import java.net.URL; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.text.ParseException; import java.util.Date; import java.util.List; import java.util.Map; import com.box.sdk.http.ContentType; import com.box.sdk.http.HttpHeaders; import com.box.sdk.http.HttpMethod; import com.eclipsesource.json.JsonArray; import com.eclipsesource.json.JsonObject; import com.eclipsesource.json.JsonValue; /** * This API provides a way to reliably upload larger files to Box by chunking them into a sequence of parts. * When using this APIinstead of the single file upload API, a request failure means a client only needs to * retry upload of a single part instead of the entire file. Parts can also be uploaded in parallel allowing * for potential performance improvement. */ @BoxResourceType("upload_session") public class BoxFileUploadSession extends BoxResource { private static final String DIGEST_HEADER_PREFIX_SHA = "sha="; private static final String DIGEST_ALGORITHM_SHA1 = "SHA1"; private static final String OFFSET_QUERY_STRING = "offset"; private static final String LIMIT_QUERY_STRING = "limit"; private Info sessionInfo; /** * Constructs a BoxFileUploadSession for a file with a given ID. * @param api the API connection to be used by the upload session. * @param id the ID of the upload session. */ BoxFileUploadSession(BoxAPIConnection api, String id) { super(api, id); } /** * Model contains the upload session information. */ public class Info extends BoxResource.Info { private Date sessionExpiresAt; private String uploadSessionId; private Endpoints sessionEndpoints; private int partSize; private int totalParts; private int partsProcessed; /** * Constructs an Info object using an already parsed JSON object. * @param jsonObject the parsed JSON object. */ Info(JsonObject jsonObject) { super(jsonObject); BoxFileUploadSession.this.sessionInfo = this; } /** * Returns the BoxFileUploadSession isntance to which this object belongs to. * @return the instance of upload session. */ public BoxFileUploadSession getResource() { return BoxFileUploadSession.this; } /** * Returns the total parts of the file that is uploaded in the upload session. * @return the total number of parts. */ public int getTotalParts() { return this.totalParts; } /** * Returns the parts that are processed so for. * @return the number of the processed parts. */ public int getPartsProcessed() { return this.partsProcessed; } /** * Returns the date and time at which the upload session expires. * @return the date and time in UTC format. */ public Date getSessionExpiresAt() { return this.sessionExpiresAt; } /** * Returns the upload session id. * @return the id string. */ public String getUploadSessionId() { return this.uploadSessionId; } /** * Returns the session endpoints that can be called for this upload session. * @return the Endpoints instance. */ public Endpoints getSessionEndpoints() { return this.sessionEndpoints; } /** * Returns the size of the each part. Only the last part of the file can be lessor than this value. * @return the part size. */ public int getPartSize() { return this.partSize; } @Override protected void parseJSONMember(JsonObject.Member member) { String memberName = member.getName(); JsonValue value = member.getValue(); if (memberName.equals("session_expires_at")) { try { String dateStr = value.asString(); this.sessionExpiresAt = BoxDateFormat.parse(dateStr.substring(0, dateStr.length() - 1) + "-00:00"); } catch (ParseException pe) { assert false : "A ParseException indicates a bug in the SDK."; } } else if (memberName.equals("id")) { this.uploadSessionId = value.asString(); } else if (memberName.equals("part_size")) { this.partSize = Integer.valueOf(value.toString()); } else if (memberName.equals("session_endpoints")) { this.sessionEndpoints = new Endpoints(value.asObject()); } else if (memberName.equals("total_parts")) { this.totalParts = value.asInt(); } else if (memberName.equals("num_parts_processed")) { this.partsProcessed = value.asInt(); } } } /** * Represents the end points specific to an upload session. */ public class Endpoints extends BoxJSONObject { private URL listPartsEndpoint; private URL commitEndpoint; private URL uploadPartEndpoint; private URL statusEndpoint; private URL abortEndpoint; /** * Constructs an Endpoints object using an already parsed JSON object. * @param jsonObject the parsed JSON object. */ Endpoints(JsonObject jsonObject) { super(jsonObject); } /** * Returns the list parts end point. * @return the url of the list parts end point. */ public URL getListPartsEndpoint() { return this.listPartsEndpoint; } /** * Returns the commit end point. * @return the url of the commit end point. */ public URL getCommitEndpoint() { return this.commitEndpoint; } /** * Returns the upload part end point. * @return the url of the upload part end point. */ public URL getUploadPartEndpoint() { return this.uploadPartEndpoint; } /** * Returns the upload session status end point. * @return the url of the session end point. */ public URL getStatusEndpoint() { return this.statusEndpoint; } /** * Returns the abort upload session end point. * @return the url of the abort end point. */ public URL getAbortEndpoint() { return this.abortEndpoint; } @Override protected void parseJSONMember(JsonObject.Member member) { String memberName = member.getName(); JsonValue value = member.getValue(); try { if (memberName.equals("list_parts")) { this.listPartsEndpoint = new URL(value.asString()); } else if (memberName.equals("commit")) { this.commitEndpoint = new URL(value.asString()); } else if (memberName.equals("upload_part")) { this.uploadPartEndpoint = new URL(value.asString()); } else if (memberName.equals("status")) { this.statusEndpoint = new URL(value.asString()); } else if (memberName.equals("abort")) { this.abortEndpoint = new URL(value.asString()); } } catch (MalformedURLException mue) { assert false : "A ParseException indicates a bug in the SDK."; } } } /** * Uploads chunk of a stream to an open upload session. * @param stream the stream that is used to read the chunck using the offset and part size. * @param offset the byte position where the chunk begins in the file. * @param partSize the part size returned as part of the upload session instance creation. * Only the last chunk can have a lesser value. * @param totalSizeOfFile The total size of the file being uploaded. * @return the part instance that contains the part id, offset and part size. */ public BoxFileUploadSessionPart uploadPart(InputStream stream, long offset, int partSize, long totalSizeOfFile) { URL uploadPartURL = this.sessionInfo.getSessionEndpoints().getUploadPartEndpoint(); BoxAPIRequest request = new BoxAPIRequest(this.getAPI(), uploadPartURL, HttpMethod.PUT); request.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_OCTET_STREAM); //Read the partSize bytes from the stream byte[] bytes = new byte[partSize]; try { stream.read(bytes); } catch (IOException ioe) { throw new BoxAPIException("Reading data from stream failed.", ioe); } return this.uploadPart(bytes, offset, partSize, totalSizeOfFile); } /** * Uploads bytes to an open upload session. * @param data data * @param offset the byte position where the chunk begins in the file. * @param partSize the part size returned as part of the upload session instance creation. * Only the last chunk can have a lesser value. * @param totalSizeOfFile The total size of the file being uploaded. * @return the part instance that contains the part id, offset and part size. */ public BoxFileUploadSessionPart uploadPart(byte[] data, long offset, int partSize, long totalSizeOfFile) { URL uploadPartURL = this.sessionInfo.getSessionEndpoints().getUploadPartEndpoint(); BoxAPIRequest request = new BoxAPIRequest(this.getAPI(), uploadPartURL, HttpMethod.PUT); request.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_OCTET_STREAM); MessageDigest digestInstance = null; try { digestInstance = MessageDigest.getInstance(DIGEST_ALGORITHM_SHA1); } catch (NoSuchAlgorithmException ae) { throw new BoxAPIException("Digest algorithm not found", ae); } //Creates the digest using SHA1 algorithm. Then encodes the bytes using Base64. byte[] digestBytes = digestInstance.digest(data); String digest = Base64.encode(digestBytes); request.addHeader(HttpHeaders.DIGEST, DIGEST_HEADER_PREFIX_SHA + digest); //Content-Range: bytes offset-part/totalSize request.addHeader(HttpHeaders.CONTENT_RANGE, "bytes " + offset + "-" + (offset + partSize - 1) + "/" + totalSizeOfFile); //Creates the body request.setBody(new ByteArrayInputStream(data)); return request.sendForUploadPart(this, offset); } /** * Returns a list of all parts that have been uploaded to an upload session. * @param offset paging marker for the list of parts. * @param limit maximum number of parts to return. * @return the list of parts. */ public BoxFileUploadSessionPartList listParts(int offset, int limit) { URL listPartsURL = this.sessionInfo.getSessionEndpoints().getListPartsEndpoint(); URLTemplate template = new URLTemplate(listPartsURL.toString()); QueryStringBuilder builder = new QueryStringBuilder(); builder.appendParam(OFFSET_QUERY_STRING, offset); String queryString = builder.appendParam(LIMIT_QUERY_STRING, limit).toString(); //Template is initalized with the full URL. So empty string for the path. URL url = template.buildWithQuery("", queryString); BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), url, HttpMethod.GET); BoxJSONResponse response = (BoxJSONResponse) request.send(); JsonObject jsonObject = JsonObject.readFrom(response.getJSON()); return new BoxFileUploadSessionPartList(jsonObject); } /** * Returns a list of all parts that have been uploaded to an upload session. * @return the list of parts. */ protected Iterable<BoxFileUploadSessionPart> listParts() { URL listPartsURL = this.sessionInfo.getSessionEndpoints().getListPartsEndpoint(); int limit = 100; return new BoxResourceIterable<BoxFileUploadSessionPart>( this.getAPI(), listPartsURL, limit) { @Override protected BoxFileUploadSessionPart factory(JsonObject jsonObject) { return new BoxFileUploadSessionPart(jsonObject); } }; } /** * Commit an upload session after all parts have been uploaded, creating the new file or the version. * @param digest the base64-encoded SHA-1 hash of the file being uploaded. * @param parts the list of uploaded parts to be committed. * @param attributes the key value pairs of attributes from the file instance. * @param ifMatch ensures that your app only alters files/folders on Box if you have the current version. * @param ifNoneMatch ensure that it retrieve unnecessary data if the most current version of file is on-hand. * @return the created file instance. */ public BoxFile.Info commit(String digest, List<BoxFileUploadSessionPart> parts, Map<String, String> attributes, String ifMatch, String ifNoneMatch) { URL commitURL = this.sessionInfo.getSessionEndpoints().getCommitEndpoint(); BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), commitURL, HttpMethod.POST); request.addHeader(HttpHeaders.DIGEST, DIGEST_HEADER_PREFIX_SHA + digest); request.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON); if (ifMatch != null) { request.addHeader(HttpHeaders.IF_MATCH, ifMatch); } if (ifNoneMatch != null) { request.addHeader(HttpHeaders.IF_NONE_MATCH, ifNoneMatch); } //Creates the body of the request String body = this.getCommitBody(parts, attributes); request.setBody(body); BoxAPIResponse response = request.send(); //Retry the commit operation after the given number of seconds if the HTTP response code is 202. if (response.getResponseCode() == 202) { String retryInterval = response.getHeaderField("retry-after"); if (retryInterval != null) { try { Thread.sleep(new Integer(retryInterval) * 1000); } catch (InterruptedException ie) { throw new BoxAPIException("Commit retry failed. ", ie); } return this.commit(digest, parts, attributes, ifMatch, ifNoneMatch); } } if (response instanceof BoxJSONResponse) { //Create the file instance from the response return this.getFile((BoxJSONResponse) response); } else { throw new BoxAPIException("Commit response content type is not application/json. The response code : " + response.getResponseCode()); } } /* * Creates the file isntance from the JSON body of the response. */ private BoxFile.Info getFile(BoxJSONResponse response) { JsonObject jsonObject = JsonObject.readFrom(response.getJSON()); JsonArray array = (JsonArray) jsonObject.get("entries"); JsonObject fileObj = (JsonObject) array.get(0); BoxFile file = new BoxFile(this.getAPI(), fileObj.get("id").asString()); return file.new Info(fileObj); } /* * Creates the JSON body for the commit request. */ private String getCommitBody(List<BoxFileUploadSessionPart> parts, Map<String, String> attributes) { JsonObject jsonObject = new JsonObject(); JsonArray array = new JsonArray(); for (BoxFileUploadSessionPart part: parts) { JsonObject partObj = new JsonObject(); partObj.add("part_id", part.getPartId()); partObj.add("offset", part.getOffset()); partObj.add("size", part.getSize()); array.add(partObj); } jsonObject.add("parts", array); if (attributes != null) { JsonObject attrObj = new JsonObject(); for (String key: attributes.keySet()) { attrObj.add(key, attributes.get(key)); } jsonObject.add("attributes", attrObj); } return jsonObject.toString(); } /** * Get the status of the upload session. It contains the number of parts that are processed so far, * the total number of parts required for the commit and expiration date and time of the upload session. * @return the status. */ public BoxFileUploadSession.Info getStatus() { URL statusURL = this.sessionInfo.getSessionEndpoints().getStatusEndpoint(); BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), statusURL, HttpMethod.GET); BoxJSONResponse response = (BoxJSONResponse) request.send(); JsonObject jsonObject = JsonObject.readFrom(response.getJSON()); this.sessionInfo.update(jsonObject); return this.sessionInfo; } /** * Abort an upload session, discarding any chunks that were uploaded to it. */ public void abort() { URL abortURL = this.sessionInfo.getSessionEndpoints().getAbortEndpoint(); BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), abortURL, HttpMethod.DELETE); request.send(); } }