Repository: smithy-lang/smithy
Pull request #2440 (2024-11-01 21:14:31)

Add delimiter and reusable to arn trait
This commit adds two fields to the aws.api#arn trait:

* resourceDelimiter - used to define the delimiter for resource segments of absolute ARN templates.
* reusable - indicates whether an ARN may be reused for different instances of a resource.

Note: two test files were migrated to `.smithy` that aren't changed otherwise. While unrelated, this is a net-positive so I've left it in.

---
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.



Patch:
diff --git a/docs/Makefile b/docs/Makefile
index 48d1df588..e668e256d 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -7,7 +7,6 @@ SHELL         := /bin/bash
 install: requirements.txt
 	python3 -m venv build/venv
 	source build/venv/bin/activate && pip3 install -r requirements.txt . && pip3 install -e .
-	touch install
 
 clean:
 	-rm -rf build/* install
diff --git a/docs/source-2.0/aws/aws-core.rst b/docs/source-2.0/aws/aws-core.rst
index fb961d890..be11b13da 100644
--- a/docs/source-2.0/aws/aws-core.rst
+++ b/docs/source-2.0/aws/aws-core.rst
@@ -290,14 +290,25 @@ members:
       - Set to true to specify that the ARN does not contain an account ID.
         If not set, or if set to false, the resolved ARN will contain a
         placeholder for the customer account ID. This can only be set to
-        true if absolute is not set or is false.
+        true if ``absolute`` is not set or is false.
     * - absolute
       - ``boolean``
       - Set to true to indicate that the ARN template contains a fully-formed
         ARN that does not need to be merged with the service. This type of
         ARN MUST be used when the identifier of a resource is an ARN or is
         based on the ARN identifier of a parent resource.
+    * - resourceDelimiter
+      - ``string``
+      - Indicates which character is used to delimit sections of the resource
+        segment of an ARN. This can only be set if ``absolute`` is set to true.
+        Valid values are ``/`` (forward slash) and ``:`` (colon).
+    * - reusable
+      - ``boolean``
+      - Set to true to indicate that an ARN may be reused for different
+        instances of a resource.
+
 
+.. _aws.api#arn-trait_format-of-an-arn:
 
 Format of an ARN
 ================
@@ -354,6 +365,8 @@ Some example ARNs from various services include:
     arn:aws:s3:::my_corporate_bucket/exampleobject.png
 
 
+.. _aws.api#arn-trait_relative-arn-templates:
+
 Relative ARN templates
 ======================
 
@@ -391,6 +404,8 @@ identifier is to be inserted into the ARN template when resolving it at
 runtime.
 
 
+.. _aws.api#arn-trait_using-an-arn-as-a-resource-identifier:
+
 Using an ARN as a resource identifier
 =====================================
 
diff --git a/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/ArnTrait.java b/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/ArnTrait.java
index c1ab461f6..3f7131dcb 100644
--- a/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/ArnTrait.java
+++ b/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/ArnTrait.java
@@ -19,11 +19,13 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import software.amazon.smithy.model.SourceException;
 import software.amazon.smithy.model.node.Node;
 import software.amazon.smithy.model.node.ObjectNode;
+import software.amazon.smithy.model.node.ToNode;
 import software.amazon.smithy.model.shapes.ShapeId;
 import software.amazon.smithy.model.traits.AbstractTrait;
 import software.amazon.smithy.model.traits.AbstractTraitBuilder;
@@ -42,6 +44,8 @@ public final class ArnTrait extends AbstractTrait implements ToSmithyBuilder<Arn
     private static final String ABSOLUTE = "absolute";
     private static final String NO_REGION = "noRegion";
     private static final String NO_ACCOUNT = "noAccount";
+    private static final String RESOURCE_DELIMITER = "resourceDelimiter";
+    private static final String REUSABLE = "reusable";
     private static final Pattern PATTERN = Pattern.compile("\\{([^}]+)}");
 
     private final boolean noRegion;
@@ -49,14 +53,18 @@ public final class ArnTrait extends AbstractTrait implements ToSmithyBuilder<Arn
     private final boolean absolute;
     private final String template;
     private final List<String> labels;
+    private final ResourceDelimiter resourceDelimiter;
+    private final boolean reusable;
 
     private ArnTrait(Builder builder) {
         super(ID, builder.getSourceLocation());
         this.template = SmithyBuilder.requiredState(TEMPLATE, builder.template);
+        this.labels = Collections.unmodifiableList(parseLabels(template));
         this.noRegion = builder.noRegion;
         this.noAccount = builder.noAccount;
         this.absolute = builder.absolute;
-        this.labels = Collections.unmodifiableList(parseLabels(template));
+        this.resourceDelimiter = builder.resourceDelimiter;
+        this.reusable = builder.reusable;
 
         if (template.startsWith("/")) {
             throw new SourceException("Invalid aws.api#arn trait. The template must not start with '/'. "
@@ -77,6 +85,8 @@ public final class ArnTrait extends AbstractTrait implements ToSmithyBuilder<Arn
             builder.absolute(objectNode.getBooleanMemberOrDefault(ABSOLUTE));
             builder.noRegion(objectNode.getBooleanMemberOrDefault(NO_REGION));
             builder.noAccount(objectNode.getBooleanMemberOrDefault(NO_ACCOUNT));
+            objectNode.getStringMember(RESOURCE_DELIMITER, builder::resourceDelimiter);
+            builder.reusable(objectNode.getBooleanMemberOrDefault(REUSABLE));
             ArnTrait result = builder.build();
             result.setNodeCache(value);
             return result;
@@ -128,12 +138,26 @@ public final class ArnTrait extends AbstractTrait implements ToSmithyBuilder<Arn
     }
 
     /**
-     * @return Returns the label place holder variable names.
+     * @return Returns the label placeholder variable names.
      */
     public List<String> getLabels() {
         return labels;
     }
 
+    /**
+     * @return Returns the resource delimiter for absolute ARNs.
+     */
+    public Optional<ResourceDelimiter> getResourceDelimiter() {
+        return Optional.ofNullable(resourceDelimiter);
+    }
+
+    /**
+     * @return Returns if the ARN may be reused for different instances of a resource.
+     */
+    public boolean isReusable() {
+        return reusable;
+    }
+
     @Override
     protected Node createNode() {
         return Node.objectNodeBuilder()
@@ -142,6 +166,8 @@ public final class ArnTrait extends AbstractTrait implements ToSmithyBuilder<Arn
                 .withMember(ABSOLUTE, Node.from(isAbsolute()))
                 .withMember(NO_ACCOUNT, Node.from(isNoAccount()))
                 .withMember(NO_REGION, Node.from(isNoRegion()))
+                .withOptionalMember(RESOURCE_DELIMITER, getResourceDelimiter())
+                .withMember(REUSABLE, Node.from(isReusable()))
                 .build();
     }
 
@@ -151,11 +177,14 @@ public final class ArnTrait extends AbstractTrait implements ToSmithyBuilder<Arn
                 .sourceLocation(getSourceLocation())
                 .noRegion(isNoRegion())
                 .noAccount(isNoAccount())
-                .template(getTemplate());
+                .absolute(isAbsolute())
+                .template(getTemplate())
+                .resourceDelimiter(resourceDelimiter)
+                .reusable(reusable);
     }
 
     // Due to the defaulting of this trait, equals has to be overridden
-    // so that inconsequential differences in toNode do not effect equality.
+    // so that inconsequential differences in toNode do not affect equality.
     @Override
     public boolean equals(Object other) {
         if (!(other instanceof ArnTrait)) {
@@ -167,13 +196,45 @@ public final class ArnTrait extends AbstractTrait implements ToSmithyBuilder<Arn
             return template.equals(oa.template)
                     && absolute == oa.absolute
                     && noAccount == oa.noAccount
-                    && noRegion == oa.noRegion;
+                    && noRegion == oa.noRegion
+                    && resourceDelimiter == oa.resourceDelimiter
+                    && reusable == oa.reusable;
         }
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(toShapeId(), template, absolute, noAccount, noRegion);
+        return Objects.hash(toShapeId(), template, absolute, noAccount, noRegion, resourceDelimiter, reusable);
+    }
+
+    public enum ResourceDelimiter implements ToNode {
+        FORWARD_SLASH("/"),
+        COLON(":");
+
+        private final String value;
+
+        ResourceDelimiter(String value) {
+            this.value = value;
+        }
+
+        static ResourceDelimiter from(String value) {
+            for (ResourceDelimiter delimiter : values()) {
+                if (delimiter.value.equals(value)) {
+                    return delimiter;
+                }
+            }
+            throw new IllegalArgumentException("Invalid delimiter: " + value);
+        }
+
+        @Override
+        public String toString() {
+            return value;
+        }
+
+        @Override
+        public Node toNode() {
+            return Node.from(value);
+        }
     }
 
     /** Builder for {@link ArnTrait}. */
@@ -182,6 +243,8 @@ public final class ArnTrait extends AbstractTrait implements ToSmithyBuilder<Arn
         private boolean noAccount;
         private boolean absolute;
         private String template;
+        private ResourceDelimiter resourceDelimiter;
+        private boolean reusable;
 
         private Builder() {}
 
@@ -209,5 +272,20 @@ public final class ArnTrait extends AbstractTrait implements ToSmithyBuilder<Arn
             this.noRegion = noRegion;
             return this;
         }
+
+        public Builder resourceDelimiter(ResourceDelimiter resourceDelimiter) {
+            this.resourceDelimiter = resourceDelimiter;
+            return this;
+        }
+
+        public Builder resourceDelimiter(String resourceDelimiter) {
+            this.resourceDelimiter = ResourceDelimiter.from(resourceDelimiter);
+            return this;
+        }
+
+        public Builder reusable(boolean reusable) {
+            this.reusable = reusable;
+            return this;
+        }
     }
 }
diff --git a/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/ArnTraitValidator.java b/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/ArnTraitValidator.java
new file mode 100644
index 000000000..889af6934
--- /dev/null
+++ b/smithy-aws-traits/src/main/java/software/amazon/smithy/aws/traits/ArnTraitValidator.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.aws.traits;
+
+import java.util.ArrayList;
+import java.util.List;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.shapes.ResourceShape;
+import software.amazon.smithy.model.validation.AbstractValidator;
+import software.amazon.smithy.model.validation.ValidationEvent;
+import software.amazon.smithy.utils.SmithyInternalApi;
+
+/**
+ * Validates that the `resourceDelimiter` field of the `aws.api#arn` trait
+ * is only used in conjunction with absolute ARNs.
+ */
+@SmithyInternalApi
+public final class ArnTraitValidator extends AbstractValidator {
+    @Override
+    public List<ValidationEvent> validate(Model model) {
+        List<ValidationEvent> events = new ArrayList<>();
+        for (ResourceShape resource : model.getResourceShapesWithTrait(ArnTrait.class)) {
+            ArnTrait trait = resource.expectTrait(ArnTrait.class);
+            if (!trait.isAbsolute() && trait.getResourceDelimiter().isPresent()) {
+                events.add(error(resource, trait, "A `resourceDelimiter` can only be set for an `absolute` ARN."));
+            }
+        }
+        return events;
+    }
+}
diff --git a/smithy-aws-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator b/smithy-aws-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator
index 80a840407..133106fc8 100644
--- a/smithy-aws-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator
+++ b/smithy-aws-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator
@@ -1,4 +1,5 @@
 software.amazon.smithy.aws.traits.ArnTemplateValidator
+software.amazon.smithy.aws.traits.ArnTraitValidator
 software.amazon.smithy.aws.traits.HttpChecksumTraitValidator
 software.amazon.smithy.aws.traits.SdkServiceIdValidator
 software.amazon.smithy.aws.traits.clientendpointdiscovery.ClientEndpointDiscoveryValidator
diff --git a/smithy-aws-traits/src/main/resources/META-INF/smithy/aws.api.smithy b/smithy-aws-traits/src/main/resources/META-INF/smithy/aws.api.smithy
index baf9cb499..a079ec698 100644
--- a/smithy-aws-traits/src/main/resources/META-INF/smithy/aws.api.smithy
+++ b/smithy-aws-traits/src/main/resources/META-INF/smithy/aws.api.smithy
@@ -38,6 +38,14 @@ structure arn {
     /// for the customer account ID. This can only be set to true if absolute
     /// is not set or is false.
     noAccount: Boolean
+
+    /// Defines which character is used to delimit sections of the resource
+    /// segment of an ARN. This can only be set if absolute is set to true.
+    resourceDelimiter: ResourceDelimiter
+
+    /// Set to true to indicate that an ARN may be reused for different
+    /// instances of a resource.
+    reusable: Boolean
 }
 
 /// Marks a string as containing an ARN.
@@ -262,6 +270,17 @@ structure taggable {
     disableSystemTags: Boolean
 }
 
+
+/// The possible delimiters for an ARN resource segment.
+@private
+enum ResourceDelimiter {
+    /// The `/` character.
+    FORWARD_SLASH = "/"
+
+    /// The `:` character.
+    COLON = ":"
+}
+
 /// A string representing a service's ARN namespace.
 @pattern("^[a-z0-9.\\-]{1,63}$")
 @private


Test patch:
diff --git a/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/ArnIndexTest.java b/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/ArnIndexTest.java
index 3bb0e340a..a81f146ae 100644
--- a/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/ArnIndexTest.java
+++ b/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/ArnIndexTest.java
@@ -34,7 +34,7 @@ public class ArnIndexTest {
     public static void beforeClass() {
         model = Model.assembler()
                 .discoverModels(ArnIndexTest.class.getClassLoader())
-                .addImport(ArnIndexTest.class.getResource("test-model.json"))
+                .addImport(ArnIndexTest.class.getResource("test-model.smithy"))
                 .assemble()
                 .unwrap();
     }
@@ -104,7 +104,7 @@ public class ArnIndexTest {
     public void findsEffectiveArns() {
         Model m = Model.assembler()
                 .discoverModels(ArnIndexTest.class.getClassLoader())
-                .addImport(ArnIndexTest.class.getResource("effective-arns.json"))
+                .addImport(ArnIndexTest.class.getResource("effective-arns.smithy"))
                 .assemble()
                 .unwrap();
         ArnIndex index = ArnIndex.of(m);
diff --git a/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/ArnReferenceTraitTest.java b/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/ArnReferenceTraitTest.java
index 6ec887db5..9e34455a9 100644
--- a/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/ArnReferenceTraitTest.java
+++ b/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/ArnReferenceTraitTest.java
@@ -76,7 +76,7 @@ public class ArnReferenceTraitTest {
     public void loadsFromModel() {
         Model result = Model.assembler()
                 .discoverModels(getClass().getClassLoader())
-                .addImport(getClass().getResource("test-model.json"))
+                .addImport(getClass().getResource("test-model.smithy"))
                 .assemble()
                 .unwrap();
         Shape service = result.expectShape(ShapeId.from("ns.foo#AbsoluteResourceArn"));
diff --git a/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/ArnTraitTest.java b/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/ArnTraitTest.java
index 2049ca99e..435c9a672 100644
--- a/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/ArnTraitTest.java
+++ b/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/ArnTraitTest.java
@@ -46,12 +46,15 @@ public class ArnTraitTest {
         assertThat(arnTrait.getTemplate(), equalTo("resourceName"));
         assertThat(arnTrait.isNoAccount(), is(false));
         assertThat(arnTrait.isNoRegion(), is(false));
+        assertThat(arnTrait.isAbsolute(), is(false));
         assertThat(arnTrait.getLabels(), empty());
+        assertThat(arnTrait.getResourceDelimiter().isPresent(), is(false));
+        assertThat(arnTrait.isReusable(), is(false));
     }
 
     @Test
-    public void canSetRegionAndServiceToNo() {
-        Node node = Node.parse("{\"noAccount\": true, \"noRegion\": true, \"absolute\": false, \"template\": \"foo\"}");
+    public void canSetOtherFields() {
+        Node node = Node.parse("{\"noAccount\": true, \"noRegion\": true, \"absolute\": false, \"template\": \"foo\", \"reusable\": true}");
         TraitFactory provider = TraitFactory.createServiceFactory();
         Optional<Trait> trait = provider.createTrait(ArnTrait.ID, ShapeId.from("ns.foo#foo"), node);
 
@@ -60,8 +63,24 @@ public class ArnTraitTest {
         assertThat(arnTrait.getTemplate(), equalTo("foo"));
         assertThat(arnTrait.isNoAccount(), is(true));
         assertThat(arnTrait.isNoRegion(), is(true));
+        assertThat(arnTrait.isAbsolute(), is(false));
         assertThat(arnTrait.toNode(), equalTo(node));
         assertThat(arnTrait.toBuilder().build(), equalTo(arnTrait));
+        assertThat(arnTrait.isReusable(), is(true));
+    }
+
+    @Test
+    public void canSetAbsoluteAndDelimiter() {
+        Node node = Node.parse("{\"absolute\": true, \"template\": \"foo\", \"resourceDelimiter\": \":\"}");
+        TraitFactory provider = TraitFactory.createServiceFactory();
+        Optional<Trait> trait = provider.createTrait(ArnTrait.ID, ShapeId.from("ns.foo#foo"), node);
+
+        assertTrue(trait.isPresent());
+        ArnTrait arnTrait = (ArnTrait) trait.get();
+        assertThat(arnTrait.getTemplate(), equalTo("foo"));
+        assertThat(arnTrait.toBuilder().build(), equalTo(arnTrait));
+        assertThat(arnTrait.isAbsolute(), is(true));
+        assertThat(arnTrait.getResourceDelimiter().get(), equalTo(ArnTrait.ResourceDelimiter.COLON));
     }
 
     @Test
diff --git a/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/ServiceTraitTest.java b/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/ServiceTraitTest.java
index 9743c4b96..7de95ce78 100644
--- a/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/ServiceTraitTest.java
+++ b/smithy-aws-traits/src/test/java/software/amazon/smithy/aws/traits/ServiceTraitTest.java
@@ -112,7 +112,7 @@ public class ServiceTraitTest {
     public void loadsFromModel() {
         Model result = Model.assembler()
                 .discoverModels(getClass().getClassLoader())
-                .addImport(getClass().getResource("test-model.json"))
+                .addImport(getClass().getResource("test-model.smithy"))
                 .assemble()
                 .unwrap();
         ServiceShape service = result
diff --git a/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/effective-arns.json b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/effective-arns.json
deleted file mode 100644
index e426d1477..000000000
--- a/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/effective-arns.json
+++ /dev/null
@@ -1,81 +0,0 @@
-{
-    "smithy": "2.0",
-    "shapes": {
-        "ns.foo#SomeService": {
-            "type": "service",
-            "version": "2018-03-17",
-            "resources": [
-                {
-                    "target": "ns.foo#Resource1"
-                }
-            ],
-            "traits": {
-                "aws.api#service": {
-                    "sdkId": "Some Value"
-                }
-            }
-        },
-        "ns.foo#Resource1": {
-            "type": "resource",
-            "resources": [
-                {
-                    "target": "ns.foo#Resource2"
-                }
-            ],
-            "traits": {
-                "aws.api#arn": {
-                    "template": "foo"
-                }
-            }
-        },
-        "ns.foo#Resource2": {
-            "type": "resource",
-            "identifiers": {
-                "id": {
-                    "target": "smithy.api#String"
-                }
-            },
-            "operations": [
-                {
-                    "target": "ns.foo#InstanceOperation"
-                }
-            ],
-            "collectionOperations": [
-                {
-                    "target": "ns.foo#CollectionOperation"
-                }
-            ],
-            "traits": {
-                "aws.api#arn": {
-                    "template": "foo/{id}"
-                }
-            }
-        },
-        "ns.foo#InstanceOperation": {
-            "type": "operation",
-            "input": {
-                "target": "ns.foo#InstanceOperationInput"
-            }
-        },
-        "ns.foo#InstanceOperationInput": {
-            "type": "structure",
-            "members": {
-                "id": {
-                    "target": "smithy.api#String",
-                    "traits": {
-                        "smithy.api#required": {}
-                    }
-                }
-            }
-        },
-        "ns.foo#CollectionOperation": {
-            "type": "operation",
-            "input": {
-                "target": "ns.foo#CollectionOperationInput"
-            }
-        },
-        "ns.foo#CollectionOperationInput": {
-            "type": "structure"
-        }
-    }
-}
diff --git a/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/effective-arns.smithy b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/effective-arns.smithy
new file mode 100644
index 000000000..467e01245
--- /dev/null
+++ b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/effective-arns.smithy
@@ -0,0 +1,49 @@
+$version: "2.0"
+
+namespace ns.foo
+
+use aws.api#arn
+use aws.api#service
+
+@service(sdkId: "Some Value")
+service SomeService {
+    version: "2018-03-17"
+    resources: [
+        Resource1
+    ]
+}
+
+@arn(template: "foo")
+resource Resource1 {
+    resources: [
+        Resource2
+    ]
+}
+
+@arn(template: "foo/{id}")
+resource Resource2 {
+    identifiers: { id: String }
+    operations: [
+        InstanceOperation
+    ]
+    collectionOperations: [
+        CollectionOperation
+    ]
+}
+
+operation CollectionOperation {
+    input: CollectionOperationInput
+    output: Unit
+}
+
+operation InstanceOperation {
+    input: InstanceOperationInput
+    output: Unit
+}
+
+structure CollectionOperationInput {}
+
+structure InstanceOperationInput {
+    @required
+    id: String
+}
diff --git a/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/arn-casing.errors b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/arn-casing.errors
new file mode 100644
index 000000000..ab77dbc31
--- /dev/null
+++ b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/arn-casing.errors
@@ -0,0 +1,1 @@
+[ERROR] ns.foo#Resource1: A `resourceDelimiter` can only be set for an `absolute` ARN. | ArnTrait
diff --git a/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/arn-casing.smithy b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/arn-casing.smithy
new file mode 100644
index 000000000..ba6857279
--- /dev/null
+++ b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/errorfiles/arn-casing.smithy
@@ -0,0 +1,49 @@
+$version: "2.0"
+
+namespace ns.foo
+
+use aws.api#arn
+use aws.api#service
+
+@service(sdkId: "Some Value")
+service SomeService {
+    version: "2018-03-17"
+    resources: [
+        Resource1
+    ]
+}
+
+@arn(template: "foo", resourceDelimiter: ":")
+resource Resource1 {
+    resources: [
+        Resource2
+    ]
+}
+
+@arn(template: "foo/{id}")
+resource Resource2 {
+    identifiers: { id: String }
+    operations: [
+        InstanceOperation
+    ]
+    collectionOperations: [
+        CollectionOperation
+    ]
+}
+
+operation CollectionOperation {
+    input: CollectionOperationInput
+    output: Unit
+}
+
+operation InstanceOperation {
+    input: InstanceOperationInput
+    output: Unit
+}
+
+structure CollectionOperationInput {}
+
+structure InstanceOperationInput {
+    @required
+    id: String
+}
diff --git a/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/test-model.json b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/test-model.json
deleted file mode 100644
index 0af22fe24..000000000
--- a/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/test-model.json
+++ /dev/null
@@ -1,131 +0,0 @@
-{
-    "smithy": "2.0",
-    "shapes": {
-        "ns.foo#NonAwsService": {
-            "type": "service",
-            "version": "2018-03-17"
-        },
-        "ns.foo#EmptyAwsService": {
-            "type": "service",
-            "version": "2018-03-17",
-            "traits": {
-                "aws.api#service": {
-                    "sdkId": "Something Empty"
-                }
-            }
-        },
-        "ns.foo#SomeService": {
-            "type": "service",
-            "version": "2018-03-17",
-            "resources": [
-                {
-                    "target": "ns.foo#SomeResource"
-                },
-                {
-                    "target": "ns.foo#RootArnResource"
-                },
-                {
-                    "target": "ns.foo#AbsoluteResource"
-                }
-            ],
-            "traits": {
-                "aws.api#service": {
-                    "sdkId": "Some Value",
-                    "arnNamespace": "service",
-                    "cloudFormationName": "SomeService",
-                    "endpointPrefix": "some-service"
-                }
-            }
-        },
-        "ns.foo#RootArnResource": {
-            "type": "resource",
-            "traits": {
-                "aws.api#arn": {
-                    "noRegion": true,
-                    "noAccount": true,
-                    "template": "rootArnResource"
-                }
-            }
-        },
-        "ns.foo#SomeResource": {
-            "type": "resource",
-            "identifiers": {
-                "someId": {
-                    "target": "ns.foo#SomeResourceId"
-                }
-            },
-            "resources": [
-                {
-                    "target": "ns.foo#ChildResource"
-                }
-            ],
-            "traits": {
-                "aws.api#arn": {
-                    "template": "someresource/{someId}"
-                }
-            }
-        },
-        "ns.foo#ChildResource": {
-            "type": "resource",
-            "identifiers": {
-                "someId": {
-                    "target": "ns.foo#SomeResourceId"
-                },
-                "childId": {
-                    "target": "ns.foo#ChildResourceId"
-                }
-            },
-            "resources": [
-                {
-                    "target": "ns.foo#AnotherChild"
-                }
-            ],
-            "traits": {
-                "aws.api#arn": {
-                    "template": "someresource/{someId}/{childId}"
-                }
-            }
-        },
-        "ns.foo#AnotherChild": {
-            "type": "resource",
-            "identifiers": {
-                "someId": {
-                    "target": "ns.foo#SomeResourceId"
-                },
-                "childId": {
-                    "target": "ns.foo#ChildResourceId"
-                }
-            }
-        },
-        "ns.foo#AbsoluteResource": {
-            "type": "resource",
-            "identifiers": {
-                "arn": {
-                    "target": "ns.foo#AbsoluteResourceArn"
-                }
-            },
-            "traits": {
-                "aws.api#arn": {
-                    "template": "{arn}",
-                    "absolute": true
-                }
-            }
-        },
-        "ns.foo#AbsoluteResourceArn": {
-            "type": "string",
-            "traits": {
-                "aws.api#arnReference": {
-                    "type": "AWS::SomeService::AbsoluteResource",
-                    "service": "ns.foo#SomeService",
-                    "resource": "ns.foo#AbsoluteResource"
-                }
-            }
-        },
-        "ns.foo#SomeResourceId": {
-            "type": "string"
-        },
-        "ns.foo#ChildResourceId": {
-            "type": "string"
-        }
-    }
-}
diff --git a/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/test-model.smithy b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/test-model.smithy
new file mode 100644
index 000000000..fd2953f25
--- /dev/null
+++ b/smithy-aws-traits/src/test/resources/software/amazon/smithy/aws/traits/test-model.smithy
@@ -0,0 +1,66 @@
+$version: "2.0"
+
+namespace ns.foo
+
+use aws.api#arn
+use aws.api#arnReference
+use aws.api#service
+
+@service(sdkId: "Something Empty")
+service EmptyAwsService { version: "2018-03-17" }
+
+service NonAwsService { version: "2018-03-17" }
+
+@service(
+    sdkId: "Some Value"
+    arnNamespace: "service"
+    cloudFormationName: "SomeService"
+    endpointPrefix: "some-service"
+)
+service SomeService {
+    version: "2018-03-17"
+    resources: [
+        AbsoluteResource
+        RootArnResource
+        SomeResource
+    ]
+}
+
+@arn(template: "{arn}", absolute: true)
+resource AbsoluteResource {
+    identifiers: { arn: AbsoluteResourceArn }
+}
+
+resource AnotherChild {
+    identifiers: { childId: ChildResourceId, someId: SomeResourceId }
+}
+
+@arn(template: "someresource/{someId}/{childId}")
+resource ChildResource {
+    identifiers: { childId: ChildResourceId, someId: SomeResourceId }
+    resources: [
+        AnotherChild
+    ]
+}
+
+@arn(noRegion: true, noAccount: true, template: "rootArnResource")
+resource RootArnResource {}
+
+@arn(template: "someresource/{someId}")
+resource SomeResource {
+    identifiers: { someId: SomeResourceId }
+    resources: [
+        ChildResource
+    ]
+}
+
+@arnReference(
+    type: "AWS::SomeService::AbsoluteResource"
+    service: "ns.foo#SomeService"
+    resource: "ns.foo#AbsoluteResource"
+)
+string AbsoluteResourceArn
+
+string ChildResourceId
+
+string SomeResourceId