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