package thredds.crawlabledataset.s3; import java.io.File; import com.google.common.base.Preconditions; /** * An identifier for objects stored in Amazon S3. Ultimately, the identifier is composed solely of a bucket name and * a key, and an object of this class can be constructed with those two items. However, this class also supports URIs * rendered in a path-like form: {@code s3://<bucket>/<key>}. * <p> * Instances of this class are immutable. * * @author cwardgar * @since 2015/08/24 */ public class S3URI { public static final String S3_PREFIX = "s3://"; public static final String S3_DELIMITER = "/"; public static final File S3ObjectTempDir = new File(System.getProperty("java.io.tmpdir"), "S3Objects"); private final String bucket, key; /** * Creates a S3URI by extracting the S3 bucket and key names from {@code uri}. If the key has a trailing * {@link #S3_DELIMITER delimiter}, it will be removed. * * @param uri an S3 URI in the form {@code s3://<bucket>/<key>}. * @throws IllegalArgumentException if {@code uri} is not in the expected form or if the bucket or key are invalid. * @see #S3URI(String, String) */ public S3URI(String uri) { if (uri.startsWith(S3_PREFIX)) { uri = uri.substring(S3_PREFIX.length(), uri.length()); int delimPos = uri.indexOf(S3_DELIMITER); if (delimPos == -1) { // Handle case where uri includes bucket but no key, e.g. "s3://bucket". this.bucket = checkBucket(uri); this.key = checkKey(null); } else { this.bucket = checkBucket(uri.substring(0, delimPos)); this.key = checkKey(uri.substring(delimPos + 1, uri.length())); } } else { throw new IllegalArgumentException(String.format( "S3 URI '%s' does not start with the expected prefix '%s'.", uri, S3_PREFIX)); } } /** * Creates a S3URI from the specified bucket and key. If the key has a trailing * {@link #S3_DELIMITER delimiter}, it will be removed. * * @param bucket a bucket name. Must be non-{@code null} and at least 3 characters. * @param key a key. May be {@code null} but cannot be the empty string. Also, it may not contain consecutive * delimiters. * @throws IllegalArgumentException if either argument fails the requirements. */ public S3URI(String bucket, String key) throws IllegalArgumentException { this.bucket = checkBucket(bucket); this.key = checkKey(key); } private static String checkBucket(String bucket) throws IllegalArgumentException { Preconditions.checkNotNull(bucket, "Bucket must be non-null."); if (bucket.length() < 3) { throw new IllegalArgumentException(String.format( "Bucket name '%s' must be at least 3 characters.", bucket)); } return bucket; } private static String checkKey(String key) throws IllegalArgumentException { if (key == null) { return null; } else if (key.equals("")) { throw new IllegalArgumentException("Key may not be the empty string."); } else { if (key.contains(S3_DELIMITER + S3_DELIMITER)) { // Key contains consecutive delimiters. throw new IllegalArgumentException(String.format("Key '%s' contains consecutive delimiters.", key)); } if (key.endsWith(S3_DELIMITER)) { return key.substring(0, key.length() - 1); // Remove trailing delimiter. } else { return key; } } } /** * Returns the bucket. * * @return the bucket. */ public String getBucket() { return bucket; } /** * Returns the key. May be {@code null} if the URI did not include a key, e.g. "s3://bucket". If not null, * any trailing {@code #S3_DELIMITER delimiter} the key had when passed to the constructor will have been stripped. * * @return the key. */ public String getKey() { return key; } /** * Returns the key, adding a trailing {@link #S3_DELIMITER delimiter}. * Returns {@code null} if the key is {@code null}. * * @return the key, with a trailing delimiter. */ public String getKeyWithTrailingDelimiter() { if (key == null) { return null; } else { assert !key.endsWith(S3_DELIMITER) : "Didn't we strip this in the ctor?"; return key + S3_DELIMITER; } } /** * Returns the base name of the file or directory denoted by this URI. This is just the last name in the * key's name sequence. If the key is {@code null}, {@code null} is returned. * * @return the base name. */ public String getBaseName() { if (key == null) { return null; } else { return new File(key).getName(); } } /** * Returns the parent URI of this URI. The determination is completely text-based, using the * {@link #S3_DELIMITER delimiter}. If the key is {@code null}, {@code null} is returned. If it is non-{@code null}, * but doesn't have a logical parent, the returned URI will have a {@code null} key (but the same bucket). * For example, the parent of {@code s3://my-bucket/my-key} (bucket="my-bucket", key="my-key") will be * {@code s3://my-bucket} (bucket=="my-bucket", key=null). * * @return the parent URI of this URI. */ public S3URI getParent() { if (key == null) { return null; } int lastDelimPos = key.lastIndexOf(S3_DELIMITER); if (lastDelimPos == -1) { return new S3URI(bucket, null); } else { return new S3URI(bucket, key.substring(0, lastDelimPos)); } } /** * Creates a new URI by resolving the specified path relative to {@code this}. If {@code key == null}, the key * of the returned URI will simply be {@code relativePath}. * * @param relativePath a path relative to {@code this}. Must be non-null. * @return the child URI. * @throws IllegalArgumentException if the path starts with a {@link #S3_DELIMITER delimiter}. * The path must be relative. */ public S3URI getChild(String relativePath) throws IllegalArgumentException { Preconditions.checkNotNull(relativePath, "relativePath must be non-null."); if (relativePath.isEmpty()) { return this; } else if (relativePath.startsWith(S3_DELIMITER)) { throw new IllegalArgumentException(String.format( "Path '%s' should be relative but begins with the delimiter string '%s'.", relativePath, S3_DELIMITER)); } if (key == null) { return new S3URI(bucket, relativePath); } else { return new S3URI(bucket, key + S3_DELIMITER + relativePath); } } /** * Gets a temporary file to which the content of the S3Object that this URI points to can be downloaded. * The path of the file is {@code ${java.io.tmpdir}/S3Objects/${hashCode()}/${getBaseName()}}. * This method does not cause the file to be created; we're just returning a suitable path. * * @return a temporary file to which the content of the S3Object that this URI points to can be downloaded. */ public File getTempFile() { // To avoid collisions of files with the same name, create a parent dir named after the S3URI's hashCode(). File parentDir = new File(S3ObjectTempDir, String.valueOf(hashCode())); return new File(parentDir, getBaseName()); } //////////////////////////////////////// Object //////////////////////////////////////// /** * Returns a string representation of the URI in the form {@code s3://<bucket>/<key>}. * * @return a string representation of the URI. */ @Override public String toString() { StringBuilder strBuilder = new StringBuilder(); strBuilder.append(S3_PREFIX); strBuilder.append(bucket); if (key != null) { strBuilder.append(S3_DELIMITER); strBuilder.append(key); } return strBuilder.toString(); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } S3URI other = (S3URI) o; return this.bucket.equals(other.bucket) && this.key == null ? other.key == null : this.key.equals(other.key); } @Override public int hashCode() { int result = bucket.hashCode(); result = 31 * result + (key != null ? key.hashCode() : 0); return result; } }