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