com.joyent.manta.http.HttpDownloadContinuationMarker.java Source code

Java tutorial

Introduction

Here is the source code for com.joyent.manta.http.HttpDownloadContinuationMarker.java

Source

/*
 * Copyright (c) 2018, Joyent, Inc. All rights reserved.
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
 */
package com.joyent.manta.http;

import com.joyent.manta.http.HttpRange.BoundedRequest;
import com.joyent.manta.http.HttpRange.Request;
import com.joyent.manta.http.HttpRange.Response;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.http.HttpException;
import org.apache.http.ProtocolException;

import java.io.InputStream;

import static org.apache.commons.lang3.Validate.notNull;
import static org.apache.commons.lang3.Validate.validState;
import static org.apache.http.HttpStatus.SC_OK;
import static org.apache.http.HttpStatus.SC_PARTIAL_CONTENT;

/**
 * Mostly-value class for recording an initial request/response cycle and retaining the necessary information to create
 * continuation requests and validate their responses.
 *
 * @author <a href="https://github.com/tjcelaya">Tomas Celaya</a>
 * @since 3.2.3
 */
final class HttpDownloadContinuationMarker {

    /**
     * The ETag associated with the object being downloaded. We need to make sure the ETag is unchanged between
     * retries.
     */
    private final String etag;

    /**
     * The starting offset of the initial request/response. Used as part of verifying that the next range will not lie
     * outside of the target request range. We need to keep this because the number of bytes provided to {@link
     * #updateRangeStart(long)} is relative to the <strong>initial</strong> start offset, not the latest start offset.
     */
    private final long originalRangeStart;

    /**
     * The total size of the target request range for Range requests, otherwise this is the Content-Length.
     */
    private final long totalRangeSize;

    /**
     * The current download range. Even if the initial request is an unbounded range, we'll know the response range
     * and always include the original value for {@code <range-end>} (derived from the content-range's end).
     */
    private BoundedRequest currentRange;

    /**
     * Build a marker from the initial ETag and response range. The Response range may be constructed from a singular
     * Content-Length or from a Content-Range header.
     *
     * @param etag the etag of the object being downloaded
     * @param initialContentRange the target range being downloaded, derived from Content-Length for entire objects
     * @see HttpRange#parseContentRange(String)
     */
    HttpDownloadContinuationMarker(final String etag, final Response initialContentRange) {
        validState(StringUtils.isNotBlank(etag), "ETag must not be null or blank");
        notNull(initialContentRange, "HttpRange must not be null");

        this.etag = etag;
        this.originalRangeStart = initialContentRange.getStartInclusive();
        this.totalRangeSize = initialContentRange.getSize();
        this.currentRange = new BoundedRequest(this.originalRangeStart, initialContentRange.getEndInclusive());
    }

    String getEtag() {
        return this.etag;
    }

    BoundedRequest getCurrentRange() {
        return this.currentRange;
    }

    long getTotalRangeSize() {
        return this.totalRangeSize;
    }

    /**
     * Advance the marker's state, updating {@link #currentRange}. Verifies that the next starting offset: 1.
     * non-negative (zero is acceptable because the user may have not read any bytes into the initial request) 2. less
     * than the previous start of the range (the user can't "unread" bytes and move backwards, {@link
     * InputStream#reset()} is not supported by resumable downloads) 3. less than the total number of bytes we expected
     * the user to read (where would those bytes come from?) 4. less than or equal to the end of the target range, this
     * is a restatement of 3 but checks our own math
     *
     * @param totalBytesRead number of bytes read across all resumed responses
     */
    void updateRangeStart(final long totalBytesRead) {
        final long nextStartInclusive = this.originalRangeStart + totalBytesRead;

        if (totalBytesRead < 0) {
            throw new IllegalArgumentException(String.format("Bytes read [%d] cannot be negative", totalBytesRead));
        }

        if (nextStartInclusive < this.currentRange.getStartInclusive()) {
            throw new IllegalArgumentException(
                    String.format("Next start position [%d] cannot decrease, previously [%d]", nextStartInclusive,
                            this.currentRange.getStartInclusive()));
        }

        if (this.totalRangeSize < totalBytesRead) {
            throw new IllegalArgumentException(
                    String.format("Bytes read [%d] cannot be greater than expected number of bytes [%d]",
                            totalBytesRead, this.totalRangeSize));
        }

        if (this.currentRange.getEndInclusive() < nextStartInclusive) {
            throw new IllegalArgumentException(
                    String.format("Next start position [%d] cannot be greater than end of range [%d]",
                            nextStartInclusive, this.currentRange.getEndInclusive()));
        }

        final BoundedRequest nextRange;
        try {
            nextRange = new BoundedRequest(nextStartInclusive, this.currentRange.getEndInclusive());
        } catch (final IllegalArgumentException e) {
            throw new IllegalArgumentException("Failed to construct updated HttpRange: " + e.getMessage(), e);
        }

        this.currentRange = nextRange;
    }

    /**
     * Verify that the Content-Range returned by a request matches the Range header that was sent. Because a {@link
     * BoundedRequest} does not contain a total object size, only the start and end offsets should be checked.
     *
     * @param responseRange the parsed Content-Range header as a {@link Response}
     * @throws HttpException in case the returned range does not match, this should've been a (416) response
     */
    void validateResponseRange(final Response responseRange) throws HttpException {
        if (!this.currentRange.matches(responseRange)) {
            throw new HttpException(String.format("Content-Range mismatch: expected: [%d-%d], got [%d-%d]",
                    this.currentRange.getStartInclusive(), this.currentRange.getEndInclusive(),
                    responseRange.getStartInclusive(), responseRange.getEndInclusive()));
        }
    }

    /**
     * Builds a download marker after confirming that the initial response matches any hints that the initial request
     * supplied. If a hint is missing it will not be checked. If the response
     *
     * @param requestHints etag and range from initial request (If-Match and Range)
     * @param responseFingerprint etag and range from initial response (ETag and Content-Range)
     * @return a marker which can be used to track download progress and verify future responses
     * @throws ProtocolException thrown when a hint is provided but not satisfied
     */
    static HttpDownloadContinuationMarker validateInitialExchange(final Pair<String, Request> requestHints,
            final int responseCode, final Pair<String, Response> responseFingerprint) throws ProtocolException {

        // there was an if-match header and the response etag does not match
        if (requestHints.getLeft() != null && !requestHints.getLeft().equals(responseFingerprint.getLeft())) {
            throw new ProtocolException(String.format("ETag does not match If-Match: If-Match [%s], ETag [%s]",
                    requestHints.getLeft(), responseFingerprint.getLeft()));

        }

        final boolean rangeRequest = requestHints.getRight() != null;

        // there was a request range and an invalid response range (or none) was returned
        // Note: we should use the more complete range (the response range) to invoke match so as many values are
        // compared as possible
        if (rangeRequest && !requestHints.getRight().matches(responseFingerprint.getRight())) {
            throw new ProtocolException(
                    String.format("Content-Range does not match Request range: Range [%s], Content-Range [%s]",
                            requestHints.getRight(), responseFingerprint.getRight()));
        }

        // if there was a request range the response code should be 206
        if (rangeRequest && responseCode != SC_PARTIAL_CONTENT) {
            throw new ProtocolException(
                    String.format("Unexpected response code for range request: expected [%d], got [%d]",
                            SC_PARTIAL_CONTENT, responseCode));
        }

        // if there was no request range the response code should be 200
        if (!rangeRequest && responseCode != SC_OK) {
            throw new ProtocolException(
                    String.format("Unexpected response code for non-range request: expected [%d], got [%d]", SC_OK,
                            responseCode));

        }

        return new HttpDownloadContinuationMarker(responseFingerprint.getLeft(), responseFingerprint.getRight());
    }

    @Override
    public String toString() {
        return "HttpDownloadContinuationMarker{" + "etag='" + this.etag + '\'' + ", originalRangeStart="
                + this.originalRangeStart + ", totalRangeSize=" + this.totalRangeSize + ", currentRange="
                + this.currentRange + '}';
    }
}