diff --git a/.changes/next-release/feature-AWSSDKforJavav2-1408e5f.json b/.changes/next-release/feature-AWSSDKforJavav2-1408e5f.json new file mode 100644 index 000000000000..a326d8e4fd23 --- /dev/null +++ b/.changes/next-release/feature-AWSSDKforJavav2-1408e5f.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "AWS SDK for Java v2", + "contributor": "", + "description": "Adding a new method of constructing ARNs without exceptions as control flow" +} diff --git a/core/arns/src/main/java/software/amazon/awssdk/arns/Arn.java b/core/arns/src/main/java/software/amazon/awssdk/arns/Arn.java index 18ac2c8f49f4..dada19e8557e 100644 --- a/core/arns/src/main/java/software/amazon/awssdk/arns/Arn.java +++ b/core/arns/src/main/java/software/amazon/awssdk/arns/Arn.java @@ -138,6 +138,23 @@ public static Builder builder() { return new DefaultBuilder(); } + /** + * Attempts to parse the given string into an {@link Arn}. If the input string is not a valid ARN, + * this method returns {@link Optional#empty()} instead of throwing an exception. + *

+ * When successful, the resource is accessible entirely as a string through + * {@link #resourceAsString()}. Where correctly formatted, a parsed resource + * containing resource type, resource and qualifier is available through + * {@link #resource()}. + * + * @param arn A string containing an ARN to parse. + * @return An {@link Optional} containing the parsed {@link Arn} if valid, or empty if invalid. + * @throws IllegalArgumentException if the ARN contains empty partition or service fields + */ + public static Optional tryFromString(String arn) { + return parseArn(arn, false); + } + /** * Parses a given string into an {@link Arn}. The resource is accessible entirely as a * string through {@link #resourceAsString()}. Where correctly formatted, a parsed @@ -148,47 +165,75 @@ public static Builder builder() { * @return {@link Arn} - A modeled Arn. */ public static Arn fromString(String arn) { + return parseArn(arn, true).orElseThrow(() -> new IllegalArgumentException("ARN parsing failed")); + } + + private static Optional parseArn(String arn, boolean throwOnError) { + if (arn == null) { + return Optional.empty(); + } + int arnColonIndex = arn.indexOf(':'); if (arnColonIndex < 0 || !"arn".equals(arn.substring(0, arnColonIndex))) { - throw new IllegalArgumentException("Malformed ARN - doesn't start with 'arn:'"); + if (throwOnError) { + throw new IllegalArgumentException("Malformed ARN - doesn't start with 'arn:'"); + } + return Optional.empty(); } int partitionColonIndex = arn.indexOf(':', arnColonIndex + 1); if (partitionColonIndex < 0) { - throw new IllegalArgumentException("Malformed ARN - no AWS partition specified"); + if (throwOnError) { + throw new IllegalArgumentException("Malformed ARN - no AWS partition specified"); + } + return Optional.empty(); } String partition = arn.substring(arnColonIndex + 1, partitionColonIndex); int serviceColonIndex = arn.indexOf(':', partitionColonIndex + 1); if (serviceColonIndex < 0) { - throw new IllegalArgumentException("Malformed ARN - no service specified"); + if (throwOnError) { + throw new IllegalArgumentException("Malformed ARN - no service specified"); + } + return Optional.empty(); } String service = arn.substring(partitionColonIndex + 1, serviceColonIndex); int regionColonIndex = arn.indexOf(':', serviceColonIndex + 1); if (regionColonIndex < 0) { - throw new IllegalArgumentException("Malformed ARN - no AWS region partition specified"); + if (throwOnError) { + throw new IllegalArgumentException("Malformed ARN - no AWS region partition specified"); + } + return Optional.empty(); } String region = arn.substring(serviceColonIndex + 1, regionColonIndex); int accountColonIndex = arn.indexOf(':', regionColonIndex + 1); if (accountColonIndex < 0) { - throw new IllegalArgumentException("Malformed ARN - no AWS account specified"); + if (throwOnError) { + throw new IllegalArgumentException("Malformed ARN - no AWS account specified"); + } + return Optional.empty(); } String accountId = arn.substring(regionColonIndex + 1, accountColonIndex); String resource = arn.substring(accountColonIndex + 1); if (resource.isEmpty()) { - throw new IllegalArgumentException("Malformed ARN - no resource specified"); + if (throwOnError) { + throw new IllegalArgumentException("Malformed ARN - no resource specified"); + } + return Optional.empty(); } - return Arn.builder() - .partition(partition) - .service(service) - .region(region) - .accountId(accountId) - .resource(resource) - .build(); + Arn resultArn = builder() + .partition(partition) + .service(service) + .region(region) + .accountId(accountId) + .resource(resource) + .build(); + + return Optional.of(resultArn); } @Override diff --git a/core/arns/src/test/java/software/amazon/awssdk/arns/ArnTest.java b/core/arns/src/test/java/software/amazon/awssdk/arns/ArnTest.java index 0e4bcfd68f0b..e95903e03cb9 100644 --- a/core/arns/src/test/java/software/amazon/awssdk/arns/ArnTest.java +++ b/core/arns/src/test/java/software/amazon/awssdk/arns/ArnTest.java @@ -17,8 +17,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertThrows; +import java.util.Optional; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; public class ArnTest { @@ -311,4 +317,78 @@ public void invalidArnWithoutAccountId_ThrowsIllegalArgumentException() { String arnString = "arn:aws:s3:us-east-1:"; assertThatThrownBy(() -> Arn.fromString(arnString)).hasMessageContaining("Malformed ARN"); } + + private static Stream validArnTestCases() { + return Stream.of( + Arguments.of("Basic resource", "arn:aws:s3:us-east-1:12345678910:myresource"), + Arguments.of("Minimal requirements", "arn:aws:foobar:::myresource"), + Arguments.of("Qualified resource", "arn:aws:s3:us-east-1:12345678910:myresource:foobar:1"), + Arguments.of("Minimal resources", "arn:aws:s3:::bucket"), + Arguments.of("Without region", "arn:aws:iam::123456789012:root"), + Arguments.of("Resource type and resource", "arn:aws:s3:us-east-1:12345678910:bucket:foobar"), + Arguments.of("Resource type And resource and qualifier", + "arn:aws:s3:us-east-1:12345678910:bucket:foobar:1"), + Arguments.of("Resource type And resource with slash", "arn:aws:s3:us-east-1:12345678910:bucket/foobar"), + Arguments.of("Resource type and resource and qualifier slash", + "arn:aws:s3:us-east-1:12345678910:bucket/foobar/1"), + Arguments.of("Without region", "arn:aws:s3::123456789012:myresource"), + Arguments.of("Without accountId", "arn:aws:s3:us-east-1::myresource"), + Arguments.of("Resource with dots", "arn:aws:s3:us-east-1:12345678910:myresource:foobar.1") + ); + } + + private static Stream invalidArnTestCases() { + return Stream.of( + Arguments.of("Without resource", "arn:aws:s3:us-east-1:12345678910:"), + Arguments.of("Invalid arn", "arn:aws:"), + Arguments.of("Doesn't start with arn", "fakearn:aws:"), + Arguments.of("Invalid without partition", "arn:"), + Arguments.of("Invalid without service", "arn:aws:"), + Arguments.of("Invalid without region", "arn:aws:s3:"), + Arguments.of("Invalid without accountId", "arn:aws:s3:us-east-1:"), + Arguments.of("Null Arn", null) + ); + } + + private static Stream exceptionThrowingArnTestCases() { + return Stream.of( + Arguments.of("Valid without partition", "arn::s3:us-east-1:12345678910:myresource"), + Arguments.of("Valid without service", "arn:aws::us-east-1:12345678910:myresource") + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("validArnTestCases") + public void optionalArnFromString_ValidArns_ReturnsPopulatedOptional(String testName, String arnString) { + Optional optionalArn = Arn.tryFromString(arnString); + + assertThat(optionalArn).isPresent(); + + Arn expectedArn = Arn.fromString(arnString); + Arn actualArn = optionalArn.get(); + + assertThat(actualArn.partition()).isEqualTo(expectedArn.partition()); + assertThat(actualArn.service()).isEqualTo(expectedArn.service()); + assertThat(actualArn.region()).isEqualTo(expectedArn.region()); + assertThat(actualArn.accountId()).isEqualTo(expectedArn.accountId()); + assertThat(actualArn.resourceAsString()).isEqualTo(expectedArn.resourceAsString()); + + assertThat(actualArn.toString()).isEqualTo(arnString); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidArnTestCases") + public void optionalArnFromString_InvalidArns_ReturnsEmptyOptional(String testName, String arnString) { + Optional optionalArn = Arn.tryFromString(arnString); + assertThat(optionalArn).isEmpty(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("exceptionThrowingArnTestCases") + public void tryFromString_InvalidArns_ShouldThrowExceptions(String testName, String arnString) { + assertThrows(IllegalArgumentException.class, () -> { + Arn.tryFromString(arnString); + }); + } + }