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(
093            this.getAPI(), uploadPartURL, HttpMethod.PUT.name(), ContentType.APPLICATION_OCTET_STREAM
094        );
095
096        MessageDigest digestInstance;
097        try {
098            digestInstance = MessageDigest.getInstance(DIGEST_ALGORITHM_SHA1);
099        } catch (NoSuchAlgorithmException ae) {
100            throw new BoxAPIException("Digest algorithm not found", ae);
101        }
102
103        //Creates the digest using SHA1 algorithm. Then encodes the bytes using Base64.
104        byte[] digestBytes = digestInstance.digest(data);
105        String digest = Base64.encode(digestBytes);
106        request.addHeader(HttpHeaders.DIGEST, DIGEST_HEADER_PREFIX_SHA + digest);
107        //Content-Range: bytes offset-part/totalSize
108        request.addHeader(HttpHeaders.CONTENT_RANGE,
109            "bytes " + offset + "-" + (offset + partSize - 1) + "/" + totalSizeOfFile);
110
111        //Creates the body
112        request.setBody(new ByteArrayInputStream(data));
113        return request.sendForUploadPart(this, offset);
114    }
115
116    /**
117     * Returns a list of all parts that have been uploaded to an upload session.
118     *
119     * @param offset paging marker for the list of parts.
120     * @param limit  maximum number of parts to return.
121     * @return the list of parts.
122     */
123    public BoxFileUploadSessionPartList listParts(int offset, int limit) {
124        URL listPartsURL = this.sessionInfo.getSessionEndpoints().getListPartsEndpoint();
125        URLTemplate template = new URLTemplate(listPartsURL.toString());
126
127        QueryStringBuilder builder = new QueryStringBuilder();
128        builder.appendParam(OFFSET_QUERY_STRING, offset);
129        String queryString = builder.appendParam(LIMIT_QUERY_STRING, limit).toString();
130
131        //Template is initalized with the full URL. So empty string for the path.
132        URL url = template.buildWithQuery("", queryString);
133
134        BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), url, HttpMethod.GET);
135        try (BoxJSONResponse response = request.send()) {
136            JsonObject jsonObject = Json.parse(response.getJSON()).asObject();
137
138            return new BoxFileUploadSessionPartList(jsonObject);
139        }
140    }
141
142    /**
143     * Returns a list of all parts that have been uploaded to an upload session.
144     *
145     * @return the list of parts.
146     */
147    protected Iterable<BoxFileUploadSessionPart> listParts() {
148        URL listPartsURL = this.sessionInfo.getSessionEndpoints().getListPartsEndpoint();
149        int limit = 100;
150        return new BoxResourceIterable<BoxFileUploadSessionPart>(
151            this.getAPI(),
152            listPartsURL,
153            limit) {
154
155            @Override
156            protected BoxFileUploadSessionPart factory(JsonObject jsonObject) {
157                return new BoxFileUploadSessionPart(jsonObject);
158            }
159        };
160    }
161
162    /**
163     * Commit an upload session after all parts have been uploaded, creating the new file or the version.
164     *
165     * @param digest      the base64-encoded SHA-1 hash of the file being uploaded.
166     * @param parts       the list of uploaded parts to be committed.
167     * @param attributes  the key value pairs of attributes from the file instance.
168     * @param ifMatch     ensures that your app only alters files/folders on Box if you have the current version.
169     * @param ifNoneMatch ensure that it retrieve unnecessary data if the most current version of file is on-hand.
170     * @return the created file instance.
171     */
172    public BoxFile.Info commit(String digest, List<BoxFileUploadSessionPart> parts,
173                               Map<String, String> attributes, String ifMatch, String ifNoneMatch) {
174
175        URL commitURL = this.sessionInfo.getSessionEndpoints().getCommitEndpoint();
176        BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), commitURL, HttpMethod.POST);
177        request.addHeader(HttpHeaders.DIGEST, DIGEST_HEADER_PREFIX_SHA + digest);
178        request.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON);
179
180        if (ifMatch != null) {
181            request.addHeader(HttpHeaders.IF_MATCH, ifMatch);
182        }
183
184        if (ifNoneMatch != null) {
185            request.addHeader(HttpHeaders.IF_NONE_MATCH, ifNoneMatch);
186        }
187
188        //Creates the body of the request
189        String body = this.getCommitBody(parts, attributes);
190        request.setBody(body);
191
192        try (BoxJSONResponse response = request.send()) {
193            //Retry the commit operation after the given number of seconds if the HTTP response code is 202.
194            if (response.getResponseCode() == 202) {
195                String retryInterval = response.getHeaderField("retry-after");
196                if (retryInterval != null) {
197                    try {
198                        Thread.sleep(new Integer(retryInterval) * 1000);
199                    } catch (InterruptedException ie) {
200                        throw new BoxAPIException("Commit retry failed. ", ie);
201                    }
202
203                    return this.commit(digest, parts, attributes, ifMatch, ifNoneMatch);
204                }
205            }
206
207            //Create the file instance from the response
208            return this.getFile(response);
209        }
210    }
211
212    /*
213     * Creates the file isntance from the JSON body of the response.
214     */
215    private BoxFile.Info getFile(BoxJSONResponse response) {
216        JsonObject jsonObject = Json.parse(response.getJSON()).asObject();
217
218        JsonArray array = (JsonArray) jsonObject.get("entries");
219        JsonObject fileObj = (JsonObject) array.get(0);
220
221        BoxFile file = new BoxFile(this.getAPI(), fileObj.get("id").asString());
222
223        return file.new Info(fileObj);
224    }
225
226    /*
227     * Creates the JSON body for the commit request.
228     */
229    private String getCommitBody(List<BoxFileUploadSessionPart> parts, Map<String, String> attributes) {
230        JsonObject jsonObject = new JsonObject();
231
232        JsonArray array = new JsonArray();
233        for (BoxFileUploadSessionPart part : parts) {
234            JsonObject partObj = new JsonObject();
235            partObj.add("part_id", part.getPartId());
236            partObj.add("offset", part.getOffset());
237            partObj.add("size", part.getSize());
238
239            array.add(partObj);
240        }
241        jsonObject.add("parts", array);
242
243        if (attributes != null) {
244            JsonObject attrObj = new JsonObject();
245            for (String key : attributes.keySet()) {
246                attrObj.add(key, attributes.get(key));
247            }
248            jsonObject.add("attributes", attrObj);
249        }
250
251        return jsonObject.toString();
252    }
253
254    /**
255     * Get the status of the upload session. It contains the number of parts that are processed so far,
256     * the total number of parts required for the commit and expiration date and time of the upload session.
257     *
258     * @return the status.
259     */
260    public BoxFileUploadSession.Info getStatus() {
261        URL statusURL = this.sessionInfo.getSessionEndpoints().getStatusEndpoint();
262        BoxJSONRequest request = new BoxJSONRequest(this.getAPI(), statusURL, HttpMethod.GET);
263        try (BoxJSONResponse response = request.send()) {
264            JsonObject jsonObject = Json.parse(response.getJSON()).asObject();
265
266            this.sessionInfo.update(jsonObject);
267
268            return this.sessionInfo;
269        }
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        BoxAPIRequest request = new BoxAPIRequest(this.getAPI(), abortURL, HttpMethod.DELETE);
278        request.send().close();
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(Json.parse(json).asObject());
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.parseInt(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 static 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}