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