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