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}