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