Repository: smithy-lang/smithy
Pull request #2031 (2023-11-06 22:02:31)

Add NodeValidationVisitor plugin for @uniqueItems
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/source-2.0/aws/aws-iam.rst b/docs/source-2.0/aws/aws-iam.rst
index 5ba48d570..d1556ead3 100644
--- a/docs/source-2.0/aws/aws-iam.rst
+++ b/docs/source-2.0/aws/aws-iam.rst
@@ -139,6 +139,10 @@ Each condition key structure supports the following members:
     * - externalDocumentation
       - ``string``
       - A valid URL that defines more information about the condition key.
+    * - relativeDocumentation
+      - ``string``
+      - A relative URL path that defines more information about the condition key
+        within a set of IAM-related documentation.
 
 .. code-block:: smithy
 
@@ -410,6 +414,10 @@ members:
     * - name
       - ``string``
       - The name of the resource in AWS IAM.
+    * - relativeDocumentation
+      - ``string``
+      - A relative URL path that defines more information about the resource
+        within a set of IAM-related documentation.
 
 The following example defines a simple resource with a name in AWS IAM that
 deviates from the :ref:`shape name of the shape ID <shape-id>` of the resource.
diff --git a/smithy-aws-iam-traits/src/main/java/software/amazon/smithy/aws/iam/traits/ConditionKeyDefinition.java b/smithy-aws-iam-traits/src/main/java/software/amazon/smithy/aws/iam/traits/ConditionKeyDefinition.java
index e0a90c4ac..da0b38d61 100644
--- a/smithy-aws-iam-traits/src/main/java/software/amazon/smithy/aws/iam/traits/ConditionKeyDefinition.java
+++ b/smithy-aws-iam-traits/src/main/java/software/amazon/smithy/aws/iam/traits/ConditionKeyDefinition.java
@@ -28,15 +28,18 @@ public final class ConditionKeyDefinition implements ToNode, ToSmithyBuilder<Con
     private static final String TYPE = "type";
     private static final String DOCUMENTATION = "documentation";
     private static final String EXTERNAL_DOCUMENTATION = "externalDocumentation";
+    private static final String RELATIVE_DOCUMENTATION = "relativeDocumentation";
 
     private final String type;
     private final String documentation;
     private final String externalDocumentation;
+    private final String relativeDocumentation;
 
     private ConditionKeyDefinition(Builder builder) {
         type = SmithyBuilder.requiredState(TYPE, builder.type);
         documentation = builder.documentation;
         externalDocumentation = builder.externalDocumentation;
+        relativeDocumentation = builder.relativeDocumentation;
     }
 
     public static Builder builder() {
@@ -51,6 +54,8 @@ public final class ConditionKeyDefinition implements ToNode, ToSmithyBuilder<Con
                 .ifPresent(builder::documentation);
         objectNode.getStringMember(EXTERNAL_DOCUMENTATION).map(StringNode::getValue)
                 .ifPresent(builder::externalDocumentation);
+        objectNode.getStringMember(RELATIVE_DOCUMENTATION).map(StringNode::getValue)
+                .ifPresent(builder::relativeDocumentation);
 
         return builder.build();
     }
@@ -76,11 +81,22 @@ public final class ConditionKeyDefinition implements ToNode, ToSmithyBuilder<Con
         return Optional.ofNullable(externalDocumentation);
     }
 
+    /**
+     * Get the relative URL path that defines more information about the condition key
+     * within a set of IAM-related documentation.
+     *
+     * @return A relative URL to the documentation page.
+     */
+    public Optional<String> getRelativeDocumentation() {
+        return Optional.ofNullable(relativeDocumentation);
+    }
+
     @Override
     public SmithyBuilder<ConditionKeyDefinition> toBuilder() {
         return builder()
                 .documentation(documentation)
                 .externalDocumentation(externalDocumentation)
+                .relativeDocumentation(relativeDocumentation)
                 .type(type);
     }
 
@@ -90,6 +106,7 @@ public final class ConditionKeyDefinition implements ToNode, ToSmithyBuilder<Con
                 .withMember(TYPE, Node.from(type))
                 .withOptionalMember(DOCUMENTATION, getDocumentation().map(Node::from))
                 .withOptionalMember(EXTERNAL_DOCUMENTATION, getExternalDocumentation().map(Node::from))
+                .withOptionalMember(RELATIVE_DOCUMENTATION, getRelativeDocumentation().map(Node::from))
                 .build();
     }
 
@@ -104,18 +121,20 @@ public final class ConditionKeyDefinition implements ToNode, ToSmithyBuilder<Con
         ConditionKeyDefinition that = (ConditionKeyDefinition) o;
         return Objects.equals(type, that.type)
                && Objects.equals(documentation, that.documentation)
-               && Objects.equals(externalDocumentation, that.externalDocumentation);
+               && Objects.equals(externalDocumentation, that.externalDocumentation)
+               && Objects.equals(relativeDocumentation, that.relativeDocumentation);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(type, documentation, externalDocumentation);
+        return Objects.hash(type, documentation, externalDocumentation, relativeDocumentation);
     }
 
     public static final class Builder implements SmithyBuilder<ConditionKeyDefinition> {
         private String type;
         private String documentation;
         private String externalDocumentation;
+        private String relativeDocumentation;
 
         @Override
         public ConditionKeyDefinition build() {
@@ -136,5 +155,10 @@ public final class ConditionKeyDefinition implements ToNode, ToSmithyBuilder<Con
             this.externalDocumentation = externalDocumentation;
             return this;
         }
+
+        public Builder relativeDocumentation(String relativeDocumentation) {
+            this.relativeDocumentation = relativeDocumentation;
+            return this;
+        }
     }
 }
diff --git a/smithy-aws-iam-traits/src/main/java/software/amazon/smithy/aws/iam/traits/IamResourceTrait.java b/smithy-aws-iam-traits/src/main/java/software/amazon/smithy/aws/iam/traits/IamResourceTrait.java
index baed36536..2e3bed2fb 100644
--- a/smithy-aws-iam-traits/src/main/java/software/amazon/smithy/aws/iam/traits/IamResourceTrait.java
+++ b/smithy-aws-iam-traits/src/main/java/software/amazon/smithy/aws/iam/traits/IamResourceTrait.java
@@ -33,10 +33,12 @@ public final class IamResourceTrait extends AbstractTrait
     public static final ShapeId ID = ShapeId.from("aws.iam#iamResource");
 
     public final String name;
+    public final String relativeDocumentation;
 
     private IamResourceTrait(Builder builder) {
         super(ID, builder.getSourceLocation());
         name = builder.name;
+        relativeDocumentation = builder.relativeDocumentation;
     }
 
     /**
@@ -48,6 +50,16 @@ public final class IamResourceTrait extends AbstractTrait
         return Optional.of(name);
     }
 
+    /**
+     * Get the relative URL path that defines more information about the resource
+     * within a set of IAM-related documentation.
+     *
+     * @return A relative URL to the documentation page.
+     */
+    public Optional<String> getRelativeDocumentation() {
+        return Optional.ofNullable(relativeDocumentation);
+    }
+
     public static Builder builder() {
         return new Builder();
     }
@@ -80,6 +92,7 @@ public final class IamResourceTrait extends AbstractTrait
 
     public static final class Builder extends AbstractTraitBuilder<IamResourceTrait, Builder> {
         private String name;
+        private String relativeDocumentation;
 
         private Builder() {}
 
@@ -92,5 +105,10 @@ public final class IamResourceTrait extends AbstractTrait
             this.name = name;
             return this;
         }
+
+        public Builder relativeDocumentation(String relativeDocumentation) {
+            this.relativeDocumentation = relativeDocumentation;
+            return this;
+        }
     }
 }
diff --git a/smithy-aws-iam-traits/src/main/resources/META-INF/smithy/aws.iam.smithy b/smithy-aws-iam-traits/src/main/resources/META-INF/smithy/aws.iam.smithy
index 3b5f585d8..ae2ca3b5e 100644
--- a/smithy-aws-iam-traits/src/main/resources/META-INF/smithy/aws.iam.smithy
+++ b/smithy-aws-iam-traits/src/main/resources/META-INF/smithy/aws.iam.smithy
@@ -39,6 +39,10 @@ structure disableConditionKeyInference {}
 structure iamResource {
     /// The name of the resource in AWS IAM.
     name: String
+
+    ///  A relative URL path that defines more information about the resource
+    ///  within a set of IAM-related documentation.
+    relativeDocumentation: String
 }
 
 /// Other actions that the invoker must be authorized to perform when executing the targeted operation.
@@ -63,14 +67,18 @@ list supportedPrincipalTypes {
 /// A defined condition key to appear within a service in addition to inferred and global condition keys.
 @private
 structure ConditionKeyDefinition {
+    @required
+    type: ConditionKeyType
+
     /// Defines documentation about the condition key.
     documentation: String
 
     /// A valid URL that defines more information about the condition key.
     externalDocumentation: String
 
-    @required
-    type: ConditionKeyType
+    ///  A relative URL path that defines more information about the condition key
+    ///  within a set of IAM-related documentation.
+    relativeDocumentation: String
 }
 
 /// The IAM policy type of the value that will supplied for this context key
diff --git a/smithy-build/src/main/java/software/amazon/smithy/build/PluginContext.java b/smithy-build/src/main/java/software/amazon/smithy/build/PluginContext.java
index a416f7c7c..f2408bd58 100644
--- a/smithy-build/src/main/java/software/amazon/smithy/build/PluginContext.java
+++ b/smithy-build/src/main/java/software/amazon/smithy/build/PluginContext.java
@@ -254,16 +254,17 @@ public final class PluginContext implements ToSmithyBuilder<PluginContext> {
 
     @Override
     public Builder toBuilder() {
-        return builder()
-                .projection(projectionName, projection)
+        Builder builder =  builder()
                 .model(model)
-                .originalModel(originalModel)
                 .events(events)
                 .settings(settings)
                 .fileManifest(fileManifest)
                 .pluginClassLoader(pluginClassLoader)
                 .sources(sources)
                 .artifactName(artifactName);
+        getProjection().ifPresent(config -> builder.projection(projectionName, config));
+        getOriginalModel().ifPresent(builder::originalModel);
+        return builder;
     }
 
     /**
diff --git a/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/directed/GenerateIntEnumDirective.java b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/directed/GenerateIntEnumDirective.java
index 1ab87b7f7..dc3518af6 100644
--- a/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/directed/GenerateIntEnumDirective.java
+++ b/smithy-codegen-core/src/main/java/software/amazon/smithy/codegen/core/directed/GenerateIntEnumDirective.java
@@ -16,6 +16,8 @@
 package software.amazon.smithy.codegen.core.directed;
 
 import software.amazon.smithy.codegen.core.CodegenContext;
+import software.amazon.smithy.model.node.ExpectationNotMetException;
+import software.amazon.smithy.model.shapes.IntEnumShape;
 import software.amazon.smithy.model.shapes.ServiceShape;
 import software.amazon.smithy.model.shapes.Shape;
 
@@ -29,6 +31,19 @@ import software.amazon.smithy.model.shapes.Shape;
 public final class GenerateIntEnumDirective<C extends CodegenContext<S, ?, ?>, S> extends ShapeDirective<Shape, C, S> {
 
     GenerateIntEnumDirective(C context, ServiceShape service, Shape shape) {
-        super(context, service, shape);
+        super(context, service, validateShape(shape));
+    }
+
+    private static Shape validateShape(Shape shape) {
+        if (shape.isIntEnumShape()) {
+            return shape;
+        }
+        throw new IllegalArgumentException("GenerateIntEnum requires an IntEnum shape");
+    }
+
+
+    public IntEnumShape expectIntEnumShape() {
+        return shape().asIntEnumShape().orElseThrow(() -> new ExpectationNotMetException(
+                "Expected an IntEnum shape, but found " + shape(), shape()));
     }
 }
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/NodeValidatorPlugin.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/NodeValidatorPlugin.java
index 3556d13ef..5398f3529 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/NodeValidatorPlugin.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/NodeValidatorPlugin.java
@@ -64,7 +64,8 @@ public interface NodeValidatorPlugin {
                 new PatternTraitPlugin(),
                 new RangeTraitPlugin(),
                 new StringEnumPlugin(),
-                new StringLengthPlugin());
+                new StringLengthPlugin(),
+                new UniqueItemsPlugin());
     }
 
     /**
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/UniqueItemsPlugin.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/UniqueItemsPlugin.java
new file mode 100644
index 000000000..99139e5a4
--- /dev/null
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/UniqueItemsPlugin.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.model.validation.node;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import software.amazon.smithy.model.node.ArrayNode;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.shapes.CollectionShape;
+import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.traits.UniqueItemsTrait;
+
+/**
+ * Validates that items in lists with the `@uniqueItems` trait are unique.
+ */
+final class UniqueItemsPlugin extends MemberAndShapeTraitPlugin<CollectionShape, ArrayNode, UniqueItemsTrait> {
+    UniqueItemsPlugin() {
+        super(CollectionShape.class, ArrayNode.class, UniqueItemsTrait.class);
+    }
+
+    @Override
+    protected void check(Shape shape, UniqueItemsTrait trait, ArrayNode value, Context context, Emitter emitter) {
+        Set<Node> uniqueNodes = new HashSet<>(value.getElements());
+        if (uniqueNodes.size() != value.size()) {
+            List<Node> duplicateNodes = new ArrayList<>(value.getElements());
+            for (Node uniqueNode : uniqueNodes) {
+                // Collections remove the first encountered entry, so our unique
+                // nodes through a set will leave behind any duplicates.
+                duplicateNodes.remove(uniqueNode);
+            }
+
+            Set<String> duplicateValues = new LinkedHashSet<>();
+            for (Node duplicateNode : duplicateNodes) {
+                duplicateValues.add(duplicateNode.toString());
+            }
+            emitter.accept(value, String.format(
+                    "Value provided for `%s` must have unique items, but the following items had multiple entries: "
+                    + "[`%s`]", shape.getId(), String.join("`, `", duplicateValues)));
+        }
+    }
+}


Test patch:
diff --git a/smithy-aws-iam-traits/src/test/java/software/amazon/smithy/aws/iam/traits/ConditionKeysIndexTest.java b/smithy-aws-iam-traits/src/test/java/software/amazon/smithy/aws/iam/traits/ConditionKeysIndexTest.java
index 9b1d6cfb6..e47054780 100644
--- a/smithy-aws-iam-traits/src/test/java/software/amazon/smithy/aws/iam/traits/ConditionKeysIndexTest.java
+++ b/smithy-aws-iam-traits/src/test/java/software/amazon/smithy/aws/iam/traits/ConditionKeysIndexTest.java
@@ -53,6 +53,9 @@ public class ConditionKeysIndexTest {
         assertThat(index.getConditionKeyNames(service, ShapeId.from("smithy.example#GetResource2")), is(empty()));
 
         // Defined context keys are assembled from the names and mapped with the definitions.
+        assertEquals(index.getDefinedConditionKeys(service).get("foo:baz").getDocumentation().get(), "Foo baz");
+        assertEquals(index.getDefinedConditionKeys(service).get("foo:baz").getRelativeDocumentation().get(),
+            "condition-keys.html");
         assertThat(index.getDefinedConditionKeys(service).get("myservice:Resource1Id1").getDocumentation(),
                 not(Optional.empty()));
         assertEquals(index.getDefinedConditionKeys(service).get("myservice:ResourceTwoId2").getDocumentation().get(),
diff --git a/smithy-aws-iam-traits/src/test/java/software/amazon/smithy/aws/iam/traits/IamResourceTraitTest.java b/smithy-aws-iam-traits/src/test/java/software/amazon/smithy/aws/iam/traits/IamResourceTraitTest.java
index 96556b185..eb7bc1837 100644
--- a/smithy-aws-iam-traits/src/test/java/software/amazon/smithy/aws/iam/traits/IamResourceTraitTest.java
+++ b/smithy-aws-iam-traits/src/test/java/software/amazon/smithy/aws/iam/traits/IamResourceTraitTest.java
@@ -36,5 +36,6 @@ public class IamResourceTraitTest {
 
         assertTrue(superResource.hasTrait(IamResourceTrait.class));
         assertEquals(superResource.expectTrait(IamResourceTrait.class).getName().get(), "super");
+        assertEquals(superResource.expectTrait(IamResourceTrait.class).getRelativeDocumentation().get(), "API-Super.html");
     }
 }
diff --git a/smithy-aws-iam-traits/src/test/resources/software/amazon/smithy/aws/iam/traits/iam-resource.smithy b/smithy-aws-iam-traits/src/test/resources/software/amazon/smithy/aws/iam/traits/iam-resource.smithy
index 76bfb1e2d..55fc02b9a 100644
--- a/smithy-aws-iam-traits/src/test/resources/software/amazon/smithy/aws/iam/traits/iam-resource.smithy
+++ b/smithy-aws-iam-traits/src/test/resources/software/amazon/smithy/aws/iam/traits/iam-resource.smithy
@@ -10,7 +10,10 @@ service MyService {
     resources: [SuperResource]
 }
 
-@aws.iam#iamResource(name: "super")
+@aws.iam#iamResource(
+    name: "super",
+    relativeDocumentation: "API-Super.html"
+)
 @arn(template: "super/{id1}")
 resource SuperResource {
     identifiers: {
diff --git a/smithy-aws-iam-traits/src/test/resources/software/amazon/smithy/aws/iam/traits/successful-condition-keys.smithy b/smithy-aws-iam-traits/src/test/resources/software/amazon/smithy/aws/iam/traits/successful-condition-keys.smithy
index 62afdc379..a7c0999c0 100644
--- a/smithy-aws-iam-traits/src/test/resources/software/amazon/smithy/aws/iam/traits/successful-condition-keys.smithy
+++ b/smithy-aws-iam-traits/src/test/resources/software/amazon/smithy/aws/iam/traits/successful-condition-keys.smithy
@@ -2,7 +2,13 @@ $version: "1.0"
 namespace smithy.example
 
 @aws.api#service(sdkId: "My")
-@aws.iam#defineConditionKeys("foo:baz": {type: "String", documentation: "Foo baz"})
+@aws.iam#defineConditionKeys(
+  "foo:baz": {
+    type: "String",
+    documentation: "Foo baz",
+    relativeDocumentation: "condition-keys.html"
+  }
+)
 service MyService {
   version: "2019-02-20",
   operations: [Operation1],
diff --git a/smithy-build/src/test/java/software/amazon/smithy/build/PluginContextTest.java b/smithy-build/src/test/java/software/amazon/smithy/build/PluginContextTest.java
index aa26b499e..f70e7724b 100644
--- a/smithy-build/src/test/java/software/amazon/smithy/build/PluginContextTest.java
+++ b/smithy-build/src/test/java/software/amazon/smithy/build/PluginContextTest.java
@@ -82,4 +82,16 @@ public class PluginContextTest {
         assertThat(context.getSources(), equalTo(context2.getSources()));
         assertThat(context.getEvents(), equalTo(context2.getEvents()));
     }
+
+    @Test
+    public void roundTripsDefaults() {
+        PluginContext before = PluginContext.builder()
+                .model(Model.builder().build())
+                .fileManifest(new MockManifest())
+                .build();
+        PluginContext after = before.toBuilder().build();
+        assertThat(before.getModel(), equalTo(after.getModel()));
+        assertThat(before.getFileManifest(), equalTo(after.getFileManifest()));
+        assertThat(before.getProjectionName(), equalTo(after.getProjectionName()));
+    }
 }
diff --git a/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/directed/GenerateIntEnumDirectiveTest.java b/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/directed/GenerateIntEnumDirectiveTest.java
new file mode 100644
index 000000000..2d582fbab
--- /dev/null
+++ b/smithy-codegen-core/src/test/java/software/amazon/smithy/codegen/core/directed/GenerateIntEnumDirectiveTest.java
@@ -0,0 +1,16 @@
+package software.amazon.smithy.codegen.core.directed;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import software.amazon.smithy.model.shapes.BooleanShape;
+
+public class GenerateIntEnumDirectiveTest {
+    @Test
+    public void validatesShapeType() {
+        BooleanShape shape = BooleanShape.builder().id("smithy.example#Foo").build();
+
+        Assertions.assertThrows(IllegalArgumentException.class, () -> {
+            new GenerateIntEnumDirective<TestContext, TestSettings>(null, null, shape);
+        });
+    }
+}
diff --git a/smithy-model/src/test/java/software/amazon/smithy/model/validation/NodeValidationVisitorTest.java b/smithy-model/src/test/java/software/amazon/smithy/model/validation/NodeValidationVisitorTest.java
index 185173a3f..091284703 100644
--- a/smithy-model/src/test/java/software/amazon/smithy/model/validation/NodeValidationVisitorTest.java
+++ b/smithy-model/src/test/java/software/amazon/smithy/model/validation/NodeValidationVisitorTest.java
@@ -207,6 +207,13 @@ public class NodeValidationVisitorTest {
                 {"ns.foo#List", "[10]", new String[] {"0: Expected string value for string shape, `ns.foo#String`; found number value, `10`"}},
                 {"ns.foo#List", "10", new String[] {"Expected array value for list shape, `ns.foo#List`; found number value, `10`"}},
 
+                // unique list
+                {"ns.foo#UniqueList", "[\"a\"]", null},
+                {"ns.foo#UniqueList", "[\"a\", \"b\"]", null},
+                {"ns.foo#UniqueList", "[\"a\", \"a\"]", new String[] {"Value provided for `ns.foo#UniqueList` must have unique items, but the following items had multiple entries: [`a`]"}},
+                {"ns.foo#UniqueList", "[\"a\", \"a\", \"a\"]", new String[] {"Value provided for `ns.foo#UniqueList` must have unique items, but the following items had multiple entries: [`a`]"}},
+                {"ns.foo#UniqueList", "[\"a\", \"a\", \"b\", \"b\"]", new String[] {"Value provided for `ns.foo#UniqueList` must have unique items, but the following items had multiple entries: [`a`, `b`]"}},
+
                 // map
                 {"ns.foo#Map", "{\"a\":[\"b\"]}", null},
                 {"ns.foo#Map", "{\"a\":[\"b\"], \"c\":[\"d\"]}", null},
diff --git a/smithy-model/src/test/resources/software/amazon/smithy/model/validation/node-validator.json b/smithy-model/src/test/resources/software/amazon/smithy/model/validation/node-validator.json
index da895424e..3b3375b42 100644
--- a/smithy-model/src/test/resources/software/amazon/smithy/model/validation/node-validator.json
+++ b/smithy-model/src/test/resources/software/amazon/smithy/model/validation/node-validator.json
@@ -133,6 +133,15 @@
                 }
             }
         },
+        "ns.foo#UniqueList": {
+            "type": "list",
+            "member": {
+                "target": "ns.foo#String"
+            },
+            "traits": {
+                "smithy.api#uniqueItems": {}
+            }
+        },
         "ns.foo#String": {
             "type": "string"
         },