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