diff --git a/.changes/next-release/feature-AmazonS3-92ece24.json b/.changes/next-release/feature-AmazonS3-92ece24.json new file mode 100644 index 000000000000..1373cde29d22 --- /dev/null +++ b/.changes/next-release/feature-AmazonS3-92ece24.json @@ -0,0 +1,6 @@ +{ + "category": "Amazon S3", + "contributor": "", + "type": "feature", + "description": "Adding feature for parsing S3 URIs" +} diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/S3Uri.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/S3Uri.java new file mode 100644 index 000000000000..b4ebc29fc939 --- /dev/null +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/S3Uri.java @@ -0,0 +1,255 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.services.s3; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import software.amazon.awssdk.annotations.Immutable; +import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.utils.CollectionUtils; +import software.amazon.awssdk.utils.ToString; +import software.amazon.awssdk.utils.Validate; +import software.amazon.awssdk.utils.builder.CopyableBuilder; +import software.amazon.awssdk.utils.builder.ToCopyableBuilder; + +/** + * Object that represents a parsed S3 URI. Can be used to easily retrieve the bucket, key, region, style, and query parameters + * of the URI. Only path-style and virtual-hosted-style URI parsing is supported, including CLI-style URIs, e.g., + * "s3://bucket/key". AccessPoints and Outposts URI parsing is not supported. If you work with object keys and/or query + * parameters with special characters, they must be URL-encoded, e.g., replace " " with "%20". If you work with + * virtual-hosted-style URIs with bucket names that contain a dot, i.e., ".", the dot must not be URL-encoded. Encoded buckets, + * keys, and query parameters will be returned decoded. + */ +@Immutable +@SdkPublicApi +public final class S3Uri implements ToCopyableBuilder { + + private final URI uri; + private final String bucket; + private final String key; + private final Region region; + private final boolean isPathStyle; + private final Map> queryParams; + + private S3Uri(Builder builder) { + this.uri = Validate.notNull(builder.uri, "URI must not be null"); + this.bucket = builder.bucket; + this.key = builder.key; + this.region = builder.region; + this.isPathStyle = Validate.notNull(builder.isPathStyle, "Path style flag must not be null"); + this.queryParams = builder.queryParams == null ? new HashMap<>() : CollectionUtils.deepCopyMap(builder.queryParams); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public Builder toBuilder() { + return new Builder(this); + } + + /** + * Returns the original URI that was used to instantiate the {@link S3Uri} + */ + public URI uri() { + return uri; + } + + /** + * Returns the bucket specified in the URI. Returns an empty optional if no bucket is specified. + */ + public Optional bucket() { + return Optional.ofNullable(bucket); + } + + /** + * Returns the key specified in the URI. Returns an empty optional if no key is specified. + */ + public Optional key() { + return Optional.ofNullable(key); + } + + /** + * Returns the region specified in the URI. Returns an empty optional if no region is specified, i.e., global endpoint. + */ + public Optional region() { + return Optional.ofNullable(region); + } + + /** + * Returns true if the URI is path-style, false if the URI is virtual-hosted style. + */ + public boolean isPathStyle() { + return isPathStyle; + } + + /** + * Returns a map of the query parameters specified in the URI. Returns an empty map if no queries are specified. + */ + public Map> rawQueryParameters() { + return queryParams; + } + + /** + * Returns the list of values for a specified query parameter. A empty list is returned if the URI does not contain the + * specified query parameter. + */ + public List firstMatchingRawQueryParameters(String key) { + List queryValues = queryParams.get(key); + if (queryValues == null) { + return new ArrayList<>(); + } + List queryValuesCopy = Arrays.asList(new String[queryValues.size()]); + Collections.copy(queryValuesCopy, queryValues); + return queryValuesCopy; + } + + /** + * Returns the value for the specified query parameter. If there are multiple values for the query parameter, the first + * value is returned. An empty optional is returned if the URI does not contain the specified query parameter. + */ + public Optional firstMatchingRawQueryParameter(String key) { + return Optional.ofNullable(queryParams.get(key)).map(q -> q.get(0)); + } + + @Override + public String toString() { + return ToString.builder("S3Uri") + .add("uri", uri) + .add("bucket", bucket) + .add("key", key) + .add("region", region) + .add("isPathStyle", isPathStyle) + .add("queryParams", queryParams) + .build(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + S3Uri s3Uri = (S3Uri) o; + return Objects.equals(uri, s3Uri.uri) + && Objects.equals(bucket, s3Uri.bucket) + && Objects.equals(key, s3Uri.key) + && Objects.equals(region, s3Uri.region) + && Objects.equals(isPathStyle, s3Uri.isPathStyle) + && Objects.equals(queryParams, s3Uri.queryParams); + } + + @Override + public int hashCode() { + int result = uri != null ? uri.hashCode() : 0; + result = 31 * result + (bucket != null ? bucket.hashCode() : 0); + result = 31 * result + (key != null ? key.hashCode() : 0); + result = 31 * result + (region != null ? region.hashCode() : 0); + result = 31 * result + Boolean.hashCode(isPathStyle); + result = 31 * result + (queryParams != null ? queryParams.hashCode() : 0); + return result; + } + + /** + * A builder for creating a {@link S3Uri} + */ + public static final class Builder implements CopyableBuilder { + private URI uri; + private String bucket; + private String key; + private Region region; + private boolean isPathStyle; + private Map> queryParams; + + private Builder() { + } + + private Builder(S3Uri s3Uri) { + this.uri = s3Uri.uri; + this.bucket = s3Uri.bucket; + this.key = s3Uri.key; + this.region = s3Uri.region; + this.isPathStyle = s3Uri.isPathStyle; + this.queryParams = s3Uri.queryParams; + } + + /** + * Configure the URI + */ + public Builder uri(URI uri) { + this.uri = uri; + return this; + } + + /** + * Configure the bucket + */ + public Builder bucket(String bucket) { + this.bucket = bucket; + return this; + } + + /** + * Configure the key + */ + public Builder key(String key) { + this.key = key; + return this; + } + + /** + * Configure the region + */ + public Builder region(Region region) { + this.region = region; + return this; + } + + /** + * Configure the path style flag + */ + public Builder isPathStyle(boolean isPathStyle) { + this.isPathStyle = isPathStyle; + return this; + } + + /** + * Configure the map of query parameters + */ + public Builder queryParams(Map> queryParams) { + this.queryParams = queryParams; + return this; + } + + @Override + public S3Uri build() { + return new S3Uri(this); + } + } + +} diff --git a/services/s3/src/main/java/software/amazon/awssdk/services/s3/S3Utilities.java b/services/s3/src/main/java/software/amazon/awssdk/services/s3/S3Utilities.java index 0a0bbc9cfc33..3e9c32b0ff4b 100644 --- a/services/s3/src/main/java/software/amazon/awssdk/services/s3/S3Utilities.java +++ b/services/s3/src/main/java/software/amazon/awssdk/services/s3/S3Utilities.java @@ -20,10 +20,14 @@ import java.net.URI; import java.net.URL; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.Consumer; import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import software.amazon.awssdk.annotations.Immutable; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.annotations.SdkPublicApi; @@ -63,7 +67,9 @@ import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.GetUrlRequest; import software.amazon.awssdk.utils.AttributeMap; +import software.amazon.awssdk.utils.StringUtils; import software.amazon.awssdk.utils.Validate; +import software.amazon.awssdk.utils.http.SdkHttpUtils; /** * Utilities for working with Amazon S3 objects. An instance of this class can be created by: @@ -94,7 +100,7 @@ @SdkPublicApi public final class S3Utilities { private static final String SERVICE_NAME = "s3"; - + private static final Pattern ENDPOINT_PATTERN = Pattern.compile("^(.+\\.)?s3[.-]([a-z0-9-]+)\\."); private final Region region; private final URI endpoint; private final S3Configuration s3Configuration; @@ -251,6 +257,162 @@ public URL getUrl(GetUrlRequest getUrlRequest) { } } + /** + * Returns a parsed {@link S3Uri} with which a user can easily retrieve the bucket, key, region, style, and query + * parameters of the URI. Only path-style and virtual-hosted-style URI parsing is supported, including CLI-style + * URIs, e.g., "s3://bucket/key". AccessPoints and Outposts URI parsing is not supported. If you work with object keys + * and/or query parameters with special characters, they must be URL-encoded, e.g., replace " " with "%20". If you work with + * virtual-hosted-style URIs with bucket names that contain a dot, i.e., ".", the dot must not be URL-encoded. Encoded + * buckets, keys, and query parameters will be returned decoded. + * + *

+ * For more information on path-style and virtual-hosted-style URIs, see Methods for accessing a bucket. + * + * @param uri The URI to be parsed + * @return Parsed {@link S3Uri} + * + *

Example Usage + *

+ * {@snippet : + * S3Client s3Client = S3Client.create(); + * S3Utilities s3Utilities = s3Client.utilities(); + * String uriString = "https://myBucket.s3.us-west-1.amazonaws.com/doc.txt?versionId=abc123"; + * URI uri = URI.create(uriString); + * S3Uri s3Uri = s3Utilities.parseUri(uri); + * + * String bucket = s3Uri.bucket().orElse(null); // "myBucket" + * String key = s3Uri.key().orElse(null); // "doc.txt" + * Region region = s3Uri.region().orElse(null); // Region.US_WEST_1 + * boolean isPathStyle = s3Uri.isPathStyle(); // false + * String versionId = s3Uri.firstMatchingRawQueryParameter("versionId").orElse(null); // "abc123" + *} + */ + public S3Uri parseUri(URI uri) { + validateUri(uri); + + if ("s3".equalsIgnoreCase(uri.getScheme())) { + return parseAwsCliStyleUri(uri); + } + + return parseStandardUri(uri); + } + + private S3Uri parseStandardUri(URI uri) { + + if (uri.getHost() == null) { + throw new IllegalArgumentException("Invalid S3 URI: no hostname: " + uri); + } + + Matcher matcher = ENDPOINT_PATTERN.matcher(uri.getHost()); + if (!matcher.find()) { + throw new IllegalArgumentException("Invalid S3 URI: hostname does not appear to be a valid S3 endpoint: " + uri); + } + + S3Uri.Builder builder = S3Uri.builder().uri(uri); + addRegionIfNeeded(builder, matcher.group(2)); + addQueryParamsIfNeeded(builder, uri); + + String prefix = matcher.group(1); + if (StringUtils.isEmpty(prefix)) { + return parsePathStyleUri(builder, uri); + } + return parseVirtualHostedStyleUri(builder, uri, matcher); + } + + private S3Uri.Builder addRegionIfNeeded(S3Uri.Builder builder, String region) { + if (!"amazonaws".equals(region)) { + return builder.region(Region.of(region)); + } + return builder; + } + + private S3Uri.Builder addQueryParamsIfNeeded(S3Uri.Builder builder, URI uri) { + if (uri.getQuery() != null) { + return builder.queryParams(SdkHttpUtils.uriParams(uri)); + } + return builder; + } + + private S3Uri parsePathStyleUri(S3Uri.Builder builder, URI uri) { + String bucket = null; + String key = null; + String path = uri.getPath(); + + if (!StringUtils.isEmpty(path) && !"/".equals(path)) { + int index = path.indexOf('/', 1); + + if (index == -1) { + // No trailing slash, e.g., "https://s3.amazonaws.com/bucket" + bucket = path.substring(1); + } else { + bucket = path.substring(1, index); + if (index != path.length() - 1) { + key = path.substring(index + 1); + } + } + } + return builder.key(key) + .bucket(bucket) + .isPathStyle(true) + .build(); + } + + private S3Uri parseVirtualHostedStyleUri(S3Uri.Builder builder, URI uri, Matcher matcher) { + String bucket; + String key = null; + String path = uri.getPath(); + String prefix = matcher.group(1); + + bucket = prefix.substring(0, prefix.length() - 1); + if (!StringUtils.isEmpty(path) && !"/".equals(path)) { + key = path.substring(1); + } + + return builder.key(key) + .bucket(bucket) + .build(); + } + + private S3Uri parseAwsCliStyleUri(URI uri) { + String key = null; + String bucket = uri.getAuthority(); + Region region = null; + boolean isPathStyle = false; + Map> queryParams = new HashMap<>(); + String path = uri.getPath(); + + if (bucket == null) { + throw new IllegalArgumentException("Invalid S3 URI: bucket not included: " + uri); + } + + if (path.length() > 1) { + key = path.substring(1); + } + + return S3Uri.builder() + .uri(uri) + .bucket(bucket) + .key(key) + .region(region) + .isPathStyle(isPathStyle) + .queryParams(queryParams) + .build(); + } + + private void validateUri(URI uri) { + Validate.paramNotNull(uri, "uri"); + + if (uri.toString().contains(".s3-accesspoint")) { + throw new IllegalArgumentException("AccessPoints URI parsing is not supported: " + uri); + } + + if (uri.toString().contains(".s3-outposts")) { + throw new IllegalArgumentException("Outposts URI parsing is not supported: " + uri); + } + } + private Region resolveRegionForGetUrl(GetUrlRequest getUrlRequest) { if (getUrlRequest.region() == null && this.region == null) { throw new IllegalArgumentException("Region should be provided either in GetUrlRequest object or S3Utilities object"); diff --git a/services/s3/src/test/java/software/amazon/awssdk/services/s3/S3UtilitiesTest.java b/services/s3/src/test/java/software/amazon/awssdk/services/s3/S3UtilitiesTest.java index 0dd1650c7bf2..1829ab488665 100644 --- a/services/s3/src/test/java/software/amazon/awssdk/services/s3/S3UtilitiesTest.java +++ b/services/s3/src/test/java/software/amazon/awssdk/services/s3/S3UtilitiesTest.java @@ -16,10 +16,11 @@ package software.amazon.awssdk.services.s3; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.net.MalformedURLException; import java.net.URI; -import java.net.URL; +import nl.jqno.equalsverifier.EqualsVerifier; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; @@ -216,6 +217,375 @@ public void getUrlWithVersionId() { .isEqualTo("https://foo.s3.us-west-2.amazonaws.com/bar?versionId=%401"); } + @Test + public void parseS3Uri_rootUri_shouldParseCorrectly() { + String uriString = "https://s3.amazonaws.com"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).isEmpty(); + assertThat(s3Uri.key()).isEmpty(); + assertThat(s3Uri.region()).isEmpty(); + assertThat(s3Uri.isPathStyle()).isTrue(); + assertThat(s3Uri.rawQueryParameters()).isEmpty(); + } + + @Test + public void parseS3Uri_rootUriTrailingSlash_shouldParseCorrectly() { + String uriString = "https://s3.amazonaws.com/"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).isEmpty(); + assertThat(s3Uri.key()).isEmpty(); + assertThat(s3Uri.region()).isEmpty(); + assertThat(s3Uri.isPathStyle()).isTrue(); + assertThat(s3Uri.rawQueryParameters()).isEmpty(); + } + + @Test + public void parseS3Uri_pathStyleTrailingSlash_shouldParseCorrectly() { + String uriString = "https://s3.us-east-1.amazonaws.com/myBucket/"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).isEmpty(); + assertThat(s3Uri.region()).contains(Region.US_EAST_1); + assertThat(s3Uri.isPathStyle()).isTrue(); + assertThat(s3Uri.rawQueryParameters()).isEmpty(); + } + + @Test + public void parseS3Uri_pathStyleGlobalEndpoint_shouldParseCorrectly() { + String uriString = "https://s3.amazonaws.com/myBucket/resources/image1.png"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).contains("resources/image1.png"); + assertThat(s3Uri.region()).isEmpty(); + assertThat(s3Uri.isPathStyle()).isTrue(); + assertThat(s3Uri.rawQueryParameters()).isEmpty(); + } + + @Test + public void parseS3Uri_virtualStyleGlobalEndpoint_shouldParseCorrectly() { + String uriString = "https://myBucket.s3.amazonaws.com/resources/image1.png"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).contains("resources/image1.png"); + assertThat(s3Uri.region()).isEmpty(); + assertThat(s3Uri.isPathStyle()).isFalse(); + assertThat(s3Uri.rawQueryParameters()).isEmpty(); + } + + @Test + public void parseS3Uri_pathStyleWithDot_shouldParseCorrectly() { + String uriString = "https://s3.eu-west-2.amazonaws.com/myBucket/resources/image1.png"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).contains("resources/image1.png"); + assertThat(s3Uri.region()).contains(Region.EU_WEST_2); + assertThat(s3Uri.isPathStyle()).isTrue(); + assertThat(s3Uri.rawQueryParameters()).isEmpty(); + } + + @Test + public void parseS3Uri_pathStyleWithDash_shouldParseCorrectly() { + String uriString = "https://s3-eu-west-2.amazonaws.com/myBucket/resources/image1.png"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).contains("resources/image1.png"); + assertThat(s3Uri.region()).contains(Region.EU_WEST_2); + assertThat(s3Uri.isPathStyle()).isTrue(); + assertThat(s3Uri.rawQueryParameters()).isEmpty(); + } + + @Test + public void parseS3Uri_virtualHostedStyleWithDot_shouldParseCorrectly() { + String uriString = "https://myBucket.s3.us-east-2.amazonaws.com/image.png"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).contains("image.png"); + assertThat(s3Uri.region()).contains(Region.US_EAST_2); + assertThat(s3Uri.isPathStyle()).isFalse(); + assertThat(s3Uri.rawQueryParameters()).isEmpty(); + } + + @Test + public void parseS3Uri_virtualHostedStyleWithDash_shouldParseCorrectly() { + String uriString = "https://myBucket.s3-us-east-2.amazonaws.com/image.png"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).contains("image.png"); + assertThat(s3Uri.region()).contains(Region.US_EAST_2); + assertThat(s3Uri.isPathStyle()).isFalse(); + assertThat(s3Uri.rawQueryParameters()).isEmpty(); + } + + @Test + public void parseS3Uri_pathStyleWithQuery_shouldParseCorrectly() { + String uriString = "https://s3.us-west-1.amazonaws.com/myBucket/doc.txt?versionId=abc123"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).contains("doc.txt"); + assertThat(s3Uri.region()).contains(Region.US_WEST_1); + assertThat(s3Uri.isPathStyle()).isTrue(); + assertThat(s3Uri.firstMatchingRawQueryParameter("versionId")).contains("abc123"); + } + + @Test + public void parseS3Uri_pathStyleWithQueryMultipleValues_shouldParseCorrectly() { + String uriString = "https://s3.us-west-1.amazonaws.com/myBucket/doc.txt?versionId=abc123&versionId=def456"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).contains("doc.txt"); + assertThat(s3Uri.region()).contains(Region.US_WEST_1); + assertThat(s3Uri.isPathStyle()).isTrue(); + assertThat(s3Uri.firstMatchingRawQueryParameter("versionId")).contains("abc123"); + assertThat(s3Uri.firstMatchingRawQueryParameters("versionId")).contains("def456"); + } + + @Test + public void parseS3Uri_pathStyleWithMultipleQueries_shouldParseCorrectly() { + String uriString = "https://s3.us-west-1.amazonaws.com/myBucket/doc.txt?versionId=abc123&partNumber=77"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).contains("doc.txt"); + assertThat(s3Uri.region()).contains(Region.US_WEST_1); + assertThat(s3Uri.isPathStyle()).isTrue(); + assertThat(s3Uri.firstMatchingRawQueryParameter("versionId")).contains("abc123"); + assertThat(s3Uri.firstMatchingRawQueryParameter("partNumber")).contains("77"); + } + + @Test + public void parseS3Uri_pathStyleWithEncoding_shouldParseCorrectly() { + String uriString = "https://s3.us-west-1.amazonaws.com/my%40Bucket/object%20key?versionId=%61%62%63%31%32%33"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("my@Bucket"); + assertThat(s3Uri.key()).contains("object key"); + assertThat(s3Uri.region()).contains(Region.US_WEST_1); + assertThat(s3Uri.isPathStyle()).isTrue(); + assertThat(s3Uri.firstMatchingRawQueryParameter("versionId")).contains("abc123"); + } + + @Test + public void parseS3Uri_virtualStyleWithQuery_shouldParseCorrectly() { + String uriString= "https://myBucket.s3.us-west-1.amazonaws.com/doc.txt?versionId=abc123"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).contains("doc.txt"); + assertThat(s3Uri.region()).contains(Region.US_WEST_1); + assertThat(s3Uri.isPathStyle()).isFalse(); + assertThat(s3Uri.firstMatchingRawQueryParameter("versionId")).contains("abc123"); + } + @Test + public void parseS3Uri_virtualStyleWithQueryMultipleValues_shouldParseCorrectly() { + String uriString = "https://myBucket.s3.us-west-1.amazonaws.com/doc.txt?versionId=abc123&versionId=def456"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).contains("doc.txt"); + assertThat(s3Uri.region()).contains(Region.US_WEST_1); + assertThat(s3Uri.isPathStyle()).isFalse(); + assertThat(s3Uri.firstMatchingRawQueryParameter("versionId")).contains("abc123"); + assertThat(s3Uri.firstMatchingRawQueryParameters("versionId")).contains("def456"); + } + + @Test + public void parseS3Uri_virtualStyleWithMultipleQueries_shouldParseCorrectly() { + String uriString = "https://myBucket.s3.us-west-1.amazonaws.com/doc.txt?versionId=abc123&partNumber=77"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).contains("doc.txt"); + assertThat(s3Uri.region()).contains(Region.US_WEST_1); + assertThat(s3Uri.isPathStyle()).isFalse(); + assertThat(s3Uri.firstMatchingRawQueryParameter("versionId")).contains("abc123"); + assertThat(s3Uri.firstMatchingRawQueryParameter("partNumber")).contains("77"); + } + + @Test + public void parseS3Uri_virtualStyleWithEncoding_shouldParseCorrectly() { + String uriString = "https://myBucket.s3.us-west-1.amazonaws.com/object%20key?versionId=%61%62%63%31%32%33"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).contains("object key"); + assertThat(s3Uri.region()).contains(Region.US_WEST_1); + assertThat(s3Uri.isPathStyle()).isFalse(); + assertThat(s3Uri.firstMatchingRawQueryParameter("versionId")).contains("abc123"); + } + + @Test + public void parseS3Uri_cliStyleWithoutKey_shouldParseCorrectly() { + String uriString = "s3://myBucket"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).isEmpty(); + assertThat(s3Uri.region()).isEmpty(); + assertThat(s3Uri.isPathStyle()).isFalse(); + } + + @Test + public void parseS3Uri_cliStyleWithoutKeyWithTrailingSlash_shouldParseCorrectly() { + String uriString = "s3://myBucket/"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).isEmpty(); + assertThat(s3Uri.region()).isEmpty(); + assertThat(s3Uri.isPathStyle()).isFalse(); + } + + @Test + public void parseS3Uri_cliStyleWithKey_shouldParseCorrectly() { + String uriString = "s3://myBucket/resources/key"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("myBucket"); + assertThat(s3Uri.key()).contains("resources/key"); + assertThat(s3Uri.region()).isEmpty(); + assertThat(s3Uri.isPathStyle()).isFalse(); + } + + @Test + public void parseS3Uri_cliStyleWithEncoding_shouldParseCorrectly() { + String uriString = "s3://my%40Bucket/object%20key"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.uri()).isEqualTo(uri); + assertThat(s3Uri.bucket()).contains("my@Bucket"); + assertThat(s3Uri.key()).contains("object key"); + assertThat(s3Uri.region()).isEmpty(); + assertThat(s3Uri.isPathStyle()).isFalse(); + } + + @Test + public void parseS3Uri_accessPointUri_shouldThrowProperErrorMessage() { + String accessPointUriString = "myendpoint-123456789012.s3-accesspoint.us-east-1.amazonaws.com"; + URI accessPointUri = URI.create(accessPointUriString); + + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + defaultUtilities.parseUri(accessPointUri); + }); + assertThat(exception.getMessage()).isEqualTo("AccessPoints URI parsing is not supported: " + + "myendpoint-123456789012.s3-accesspoint.us-east-1.amazonaws.com"); + } + + @Test + public void parseS3Uri_accessPointUriWithFipsDualstack_shouldThrowProperErrorMessage() { + String accessPointUriString = "myendpoint-123456789012.s3-accesspoint-fips.dualstack.us-gov-east-1.amazonaws.com"; + URI accessPointUri = URI.create(accessPointUriString); + + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + defaultUtilities.parseUri(accessPointUri); + }); + assertThat(exception.getMessage()).isEqualTo("AccessPoints URI parsing is not supported: " + + "myendpoint-123456789012.s3-accesspoint-fips.dualstack.us-gov-east-1.amazonaws.com"); + } + + @Test + public void parseS3Uri_outpostsUri_shouldThrowProperErrorMessage() { + String outpostsUriString = "myaccesspoint-123456789012.op-01234567890123456.s3-outposts.us-west-2.amazonaws.com"; + URI outpostsUri = URI.create(outpostsUriString); + + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + defaultUtilities.parseUri(outpostsUri); + }); + assertThat(exception.getMessage()).isEqualTo("Outposts URI parsing is not supported: " + + "myaccesspoint-123456789012.op-01234567890123456.s3-outposts.us-west-2.amazonaws.com"); + } + + @Test + public void parseS3Uri_outpostsUriWithChinaPartition_shouldThrowProperErrorMessage() { + String outpostsUriString = "myaccesspoint-123456789012.op-01234567890123456.s3-outposts.cn-north-1.amazonaws.com.cn"; + URI outpostsUri = URI.create(outpostsUriString); + + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + defaultUtilities.parseUri(outpostsUri); + }); + assertThat(exception.getMessage()).isEqualTo("Outposts URI parsing is not supported: " + + "myaccesspoint-123456789012.op-01234567890123456.s3-outposts.cn-north-1.amazonaws.com.cn"); + } + + @Test + public void parseS3Uri_withNonS3Uri_shouldThrowProperErrorMessage() { + String nonS3UriString = "https://www.amazon.com/"; + URI nonS3Uri = URI.create(nonS3UriString); + + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + defaultUtilities.parseUri(nonS3Uri); + }); + assertThat(exception.getMessage()).isEqualTo("Invalid S3 URI: hostname does not appear to be a valid S3 endpoint: " + + "https://www.amazon.com/"); + } + + @Test + public void S3Uri_toString_printsCorrectOutput() { + String uriString = "https://myBucket.s3.us-west-1.amazonaws.com/doc.txt?versionId=abc123&partNumber=77"; + URI uri = URI.create(uriString); + + S3Uri s3Uri = defaultUtilities.parseUri(uri); + assertThat(s3Uri.toString()).isEqualTo("S3Uri(uri=https://myBucket.s3.us-west-1.amazonaws.com/doc.txt?" + + "versionId=abc123&partNumber=77, bucket=myBucket, key=doc.txt, region=us-west-1," + + " isPathStyle=false, queryParams={versionId=[abc123], partNumber=[77]})"); + } + + @Test + public void S3Uri_testEqualsAndHashCodeContract() { + EqualsVerifier.forClass(S3Uri.class).verify(); + } + private static GetUrlRequest requestWithoutSpaces() { return GetUrlRequest.builder() .bucket("foo-bucket")