Repository: smithy-lang/smithy
Pull request #1949 (2023-08-24 22:17:27)

Allow constraint trait errors on error example inputs
This PR adds a `disableConstraints` boolean to the @examples trait, allowing for input constraint trait validations to be lowered from `ERROR` to `WARNING`.

By lowering the severity from an `ERROR`, Smithy model authors can include examples of invalid inputs and their corresponding errors.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.


srchase: > I don't think error definitions using values that don't match the types of for the error are possible to use. If examples let you give exact HTTP messages or something, then sure. But they're structured based on the model. So I think we would want to disable constraints, but not type checking.

Update this PR to only allow lowering specific trait validations. Type checking validation issues are still emitted as an `ERROR`.

Patch:
diff --git a/docs/source-2.0/guides/building-models/build-config.rst b/docs/source-2.0/guides/building-models/build-config.rst
index b713aeda5..dd13aa32e 100644
--- a/docs/source-2.0/guides/building-models/build-config.rst
+++ b/docs/source-2.0/guides/building-models/build-config.rst
@@ -61,7 +61,7 @@ The configuration file accepts the following properties:
         projection. Plugins are a mapping of :ref:`plugin IDs <plugin-id>` to
         plugin-specific configuration objects.
     * - ignoreMissingPlugins
-      - ``bool``
+      - ``boolean``
       - If a plugin can't be found, Smithy will by default fail the build. This
         setting can be set to ``true`` to allow the build to progress even if
         a plugin can't be found on the classpath.
diff --git a/docs/source-2.0/spec/documentation-traits.rst b/docs/source-2.0/spec/documentation-traits.rst
index ed254072f..f0748626a 100644
--- a/docs/source-2.0/spec/documentation-traits.rst
+++ b/docs/source-2.0/spec/documentation-traits.rst
@@ -139,11 +139,10 @@ Each ``example`` trait value is a structure with the following members:
       - :ref:`examples-ErrorExample-structure`
       - Provides an error shape ID and example error parameters for the
         operation.
-
-The values provided for the ``input`` and ``output`` members MUST be
-compatible with the shapes and constraints of the corresponding structure.
-These values use the same semantics and format as
-:ref:`custom trait values <trait-node-values>`.
+    * - allowConstraintErrors
+      - ``boolean``
+      - Set to true to lower input constraint trait validations to warnings.
+        This can only be set when ``error`` is provided.
 
 A value for ``output`` or ``error`` SHOULD be provided. However, both
 MUST NOT be defined for the same example.
@@ -186,7 +185,8 @@ MUST NOT be defined for the same example.
                 content: {
                     message: "Invalid 'foo'. Special character not allowed."
                 }
-            }
+            },
+            allowConstraintErrors: true
         }
     ])
 
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/traits/ExamplesTrait.java b/smithy-model/src/main/java/software/amazon/smithy/model/traits/ExamplesTrait.java
index 7ff28c079..fe46e028f 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/traits/ExamplesTrait.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/traits/ExamplesTrait.java
@@ -20,6 +20,7 @@ import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
 import software.amazon.smithy.model.node.ArrayNode;
+import software.amazon.smithy.model.node.BooleanNode;
 import software.amazon.smithy.model.node.Node;
 import software.amazon.smithy.model.node.ObjectNode;
 import software.amazon.smithy.model.node.ToNode;
@@ -55,6 +56,24 @@ public final class ExamplesTrait extends AbstractTrait implements ToSmithyBuilde
         return examples.stream().map(Example::toNode).collect(ArrayNode.collect(getSourceLocation()));
     }
 
+    @Override
+    public boolean equals(Object other) {
+        if (!(other instanceof ExamplesTrait)) {
+            return false;
+        } else if (other == this) {
+            return true;
+        } else {
+            ExamplesTrait trait = (ExamplesTrait) other;
+            return this.examples.size() == trait.examples.size() && this.examples.containsAll(trait.examples);
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(toShapeId(), examples);
+    }
+
+
     @Override
     public Builder toBuilder() {
         Builder builder = new Builder().sourceLocation(getSourceLocation());
@@ -70,7 +89,7 @@ public final class ExamplesTrait extends AbstractTrait implements ToSmithyBuilde
     }
 
     /**
-     * Builds and examples trait.
+     * Builds an examples trait.
      */
     public static final class Builder extends AbstractTraitBuilder<ExamplesTrait, Builder> {
         private final List<Example> examples = new ArrayList<>();
@@ -100,6 +119,7 @@ public final class ExamplesTrait extends AbstractTrait implements ToSmithyBuilde
         private final ObjectNode input;
         private final ObjectNode output;
         private final ErrorExample error;
+        private final boolean allowConstraintErrors;
 
         private Example(Builder builder) {
             this.title = Objects.requireNonNull(builder.title, "Example title must not be null");
@@ -107,6 +127,7 @@ public final class ExamplesTrait extends AbstractTrait implements ToSmithyBuilde
             this.input = builder.input;
             this.output = builder.output;
             this.error = builder.error;
+            this.allowConstraintErrors = builder.allowConstraintErrors;
         }
 
         /**
@@ -144,12 +165,21 @@ public final class ExamplesTrait extends AbstractTrait implements ToSmithyBuilde
             return Optional.ofNullable(error);
         }
 
+        /**
+         * @return Returns true if input constraints errors are allowed.
+         */
+        public boolean getAllowConstraintErrors() {
+            return allowConstraintErrors;
+        }
+
         @Override
         public Node toNode() {
             ObjectNode.Builder builder = Node.objectNodeBuilder()
                     .withMember("title", Node.from(title))
                     .withOptionalMember("documentation", getDocumentation().map(Node::from))
-                    .withOptionalMember("error", getError().map(ErrorExample::toNode));
+                    .withOptionalMember("error", getError().map(ErrorExample::toNode))
+                    .withOptionalMember("allowConstraintErrors", BooleanNode.from(allowConstraintErrors)
+                            .asBooleanNode());
 
             if (!input.isEmpty()) {
                 builder.withMember("input", input);
@@ -163,7 +193,8 @@ public final class ExamplesTrait extends AbstractTrait implements ToSmithyBuilde
 
         @Override
         public Builder toBuilder() {
-            return new Builder().documentation(documentation).title(title).input(input).output(output).error(error);
+            return new Builder().documentation(documentation).title(title).input(input).output(output).error(error)
+                    .allowConstraintErrors(allowConstraintErrors);
         }
 
         public static Builder builder() {
@@ -179,6 +210,7 @@ public final class ExamplesTrait extends AbstractTrait implements ToSmithyBuilde
             private ObjectNode input = Node.objectNode();
             private ObjectNode output;
             private ErrorExample error;
+            private boolean allowConstraintErrors;
 
             @Override
             public Example build() {
@@ -209,6 +241,11 @@ public final class ExamplesTrait extends AbstractTrait implements ToSmithyBuilde
                 this.error = error;
                 return this;
             }
+
+            public Builder allowConstraintErrors(Boolean allowConstraintErrors) {
+                this.allowConstraintErrors = allowConstraintErrors;
+                return this;
+            }
         }
     }
 
@@ -302,7 +339,8 @@ public final class ExamplesTrait extends AbstractTrait implements ToSmithyBuilde
                     .getStringMember("documentation", builder::documentation)
                     .getObjectMember("input", builder::input)
                     .getObjectMember("output", builder::output)
-                    .getMember("error", ErrorExample::fromNode, builder::error);
+                    .getMember("error", ErrorExample::fromNode, builder::error)
+                    .getBooleanMember("allowConstraintErrors", builder::allowConstraintErrors);
             return builder.build();
         }
     }
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/NodeValidationVisitor.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/NodeValidationVisitor.java
index 733196c1d..330b377f7 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/validation/NodeValidationVisitor.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/NodeValidationVisitor.java
@@ -104,10 +104,21 @@ public final class NodeValidationVisitor implements ShapeVisitor<List<Validation
          * Emit a warning when a range trait is incompatible with a default value of 0.
          *
          * <p>This was a common pattern in Smithy 1.0 and earlier. It implies that the value is effectively
-         * required. However, chaning the type of the value by un-boxing it or adjusting the range trait would
-         * be a lossy tranformation when migrating a model from 1.0 to 2.0.
+         * required. However, changing the type of the value by un-boxing it or adjusting the range trait would
+         * be a lossy transformation when migrating a model from 1.0 to 2.0.
          */
-        RANGE_TRAIT_ZERO_VALUE_WARNING
+        RANGE_TRAIT_ZERO_VALUE_WARNING,
+
+        // Lowers severity of constraint trait validations to WARNING.
+        ALLOW_CONSTRAINT_ERRORS;
+
+        public static Feature fromNode(Node node) {
+            return Feature.valueOf(node.expectStringNode().getValue());
+        }
+
+        public static Node toNode(Feature feature) {
+            return StringNode.from(feature.toString());
+        }
     }
 
     public static Builder builder() {
@@ -317,9 +328,11 @@ public final class NodeValidationVisitor implements ShapeVisitor<List<Validation
 
                     for (MemberShape member : members.values()) {
                         if (member.isRequired() && !object.getMember(member.getMemberName()).isPresent()) {
+                            Severity severity = this.validationContext.hasFeature(Feature.ALLOW_CONSTRAINT_ERRORS)
+                                    ? Severity.WARNING : Severity.ERROR;
                             events.add(event(String.format(
                                     "Missing required structure member `%s` for `%s`",
-                                    member.getMemberName(), shape.getId())));
+                                    member.getMemberName(), shape.getId()), severity));
                         }
                     }
                     return events;
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/BlobLengthPlugin.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/BlobLengthPlugin.java
index 9b566083f..1b6047f40 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/BlobLengthPlugin.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/BlobLengthPlugin.java
@@ -20,6 +20,8 @@ import software.amazon.smithy.model.node.StringNode;
 import software.amazon.smithy.model.shapes.BlobShape;
 import software.amazon.smithy.model.shapes.Shape;
 import software.amazon.smithy.model.traits.LengthTrait;
+import software.amazon.smithy.model.validation.NodeValidationVisitor;
+import software.amazon.smithy.model.validation.Severity;
 import software.amazon.smithy.utils.SmithyInternalApi;
 
 /**
@@ -39,16 +41,23 @@ final class BlobLengthPlugin extends MemberAndShapeTraitPlugin<BlobShape, String
 
         trait.getMin().ifPresent(min -> {
             if (size < min) {
-                emitter.accept(node, "Value provided for `" + shape.getId() + "` must have at least "
-                                     + min + " bytes, but the provided value only has " + size + " bytes");
+                emitter.accept(node, getSeverity(context), "Value provided for `" + shape.getId()
+                            + "` must have at least " + min + " bytes, but the provided value only has " + size
+                            + " bytes");
             }
         });
 
         trait.getMax().ifPresent(max -> {
             if (value.getBytes(StandardCharsets.UTF_8).length > max) {
-                emitter.accept(node, "Value provided for `" + shape.getId() + "` must have no more than "
-                                     + max + " bytes, but the provided value has " + size + " bytes");
+                emitter.accept(node, getSeverity(context), "Value provided for `" + shape.getId()
+                            + "` must have no more than " + max + " bytes, but the provided value has " + size
+                            + " bytes");
             }
         });
     }
+
+    private Severity getSeverity(Context context) {
+        return context.hasFeature(NodeValidationVisitor.Feature.ALLOW_CONSTRAINT_ERRORS)
+                ? Severity.WARNING : Severity.ERROR;
+    }
 }
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/MapLengthPlugin.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/MapLengthPlugin.java
index ac06ed765..b73895526 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/MapLengthPlugin.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/MapLengthPlugin.java
@@ -19,6 +19,8 @@ import software.amazon.smithy.model.node.ObjectNode;
 import software.amazon.smithy.model.shapes.MapShape;
 import software.amazon.smithy.model.shapes.Shape;
 import software.amazon.smithy.model.traits.LengthTrait;
+import software.amazon.smithy.model.validation.NodeValidationVisitor;
+import software.amazon.smithy.model.validation.Severity;
 import software.amazon.smithy.utils.SmithyInternalApi;
 
 /**
@@ -35,7 +37,7 @@ final class MapLengthPlugin extends MemberAndShapeTraitPlugin<MapShape, ObjectNo
     protected void check(Shape shape, LengthTrait trait, ObjectNode node, Context context, Emitter emitter) {
         trait.getMin().ifPresent(min -> {
             if (node.size() < min) {
-                emitter.accept(node, String.format(
+                emitter.accept(node, getSeverity(context), String.format(
                         "Value provided for `%s` must have at least %d entries, but the provided value only "
                         + "has %d entries", shape.getId(), min, node.size()));
             }
@@ -43,10 +45,15 @@ final class MapLengthPlugin extends MemberAndShapeTraitPlugin<MapShape, ObjectNo
 
         trait.getMax().ifPresent(max -> {
             if (node.size() > max) {
-                emitter.accept(node, String.format(
+                emitter.accept(node, getSeverity(context), String.format(
                         "Value provided for `%s` must have no more than %d entries, but the provided value "
                         + "has %d entries", shape.getId(), max, node.size()));
             }
         });
     }
+
+    private Severity getSeverity(Context context) {
+        return context.hasFeature(NodeValidationVisitor.Feature.ALLOW_CONSTRAINT_ERRORS)
+                ? Severity.WARNING : Severity.ERROR;
+    }
 }
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/PatternTraitPlugin.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/PatternTraitPlugin.java
index 987b3d002..7bb1d2024 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/PatternTraitPlugin.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/PatternTraitPlugin.java
@@ -19,6 +19,8 @@ import software.amazon.smithy.model.node.StringNode;
 import software.amazon.smithy.model.shapes.Shape;
 import software.amazon.smithy.model.shapes.StringShape;
 import software.amazon.smithy.model.traits.PatternTrait;
+import software.amazon.smithy.model.validation.NodeValidationVisitor;
+import software.amazon.smithy.model.validation.Severity;
 
 /**
  * Validates the pattern trait on string shapes or members that target them.
@@ -32,9 +34,15 @@ final class PatternTraitPlugin extends MemberAndShapeTraitPlugin<StringShape, St
     @Override
     protected void check(Shape shape, PatternTrait trait, StringNode node, Context context, Emitter emitter) {
         if (!trait.getPattern().matcher(node.getValue()).find()) {
-            emitter.accept(node, String.format(
+            emitter.accept(node, getSeverity(context), String.format(
                     "String value provided for `%s` must match regular expression: %s",
                     shape.getId(), trait.getPattern().pattern()));
         }
     }
+
+
+    private Severity getSeverity(Context context) {
+        return context.hasFeature(NodeValidationVisitor.Feature.ALLOW_CONSTRAINT_ERRORS)
+                ? Severity.WARNING : Severity.ERROR;
+    }
 }
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/RangeTraitPlugin.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/RangeTraitPlugin.java
index 571a184a8..0dbfc5db9 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/RangeTraitPlugin.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/RangeTraitPlugin.java
@@ -36,33 +36,32 @@ class RangeTraitPlugin implements NodeValidatorPlugin {
     public final void apply(Shape shape, Node value, Context context, Emitter emitter) {
         if (shape.hasTrait(RangeTrait.class)) {
             if (value.isNumberNode()) {
-                boolean zeroValueWarning = context
-                        .hasFeature(NodeValidationVisitor.Feature.RANGE_TRAIT_ZERO_VALUE_WARNING);
-                check(shape, zeroValueWarning, shape.expectTrait(RangeTrait.class), value.expectNumberNode(), emitter);
+                check(shape, context, shape.expectTrait(RangeTrait.class), value.expectNumberNode(), emitter);
             } else if (value.isStringNode()) {
-                checkNonNumeric(shape, shape.expectTrait(RangeTrait.class), value.expectStringNode(), emitter);
+                checkNonNumeric(shape, shape.expectTrait(RangeTrait.class), value.expectStringNode(), emitter,
+                        context);
             }
         }
     }
 
-    private void checkNonNumeric(Shape shape, RangeTrait trait, StringNode node, Emitter emitter) {
+    private void checkNonNumeric(Shape shape, RangeTrait trait, StringNode node, Emitter emitter, Context context) {
         NonNumericFloat.fromStringRepresentation(node.getValue()).ifPresent(value -> {
             if (value.equals(NonNumericFloat.NAN)) {
-                emitter.accept(node, Severity.ERROR, String.format(
+                emitter.accept(node, getSeverity(context), String.format(
                         "Value provided for `%s` must be a number because the `smithy.api#range` trait is applied, "
                                 + "but found \"%s\"",
                         shape.getId(), node.getValue()));
             }
 
             if (trait.getMin().isPresent() && value.equals(NonNumericFloat.NEGATIVE_INFINITY)) {
-                emitter.accept(node, Severity.ERROR, String.format(
+                emitter.accept(node, getSeverity(context), String.format(
                         "Value provided for `%s` must be greater than or equal to %s, but found \"%s\"",
                         shape.getId(), trait.getMin().get(), node.getValue()),
                         shape.isMemberShape() ? MEMBER : TARGET);
             }
 
             if (trait.getMax().isPresent() && value.equals(NonNumericFloat.POSITIVE_INFINITY)) {
-                emitter.accept(node, Severity.ERROR, String.format(
+                emitter.accept(node, getSeverity(context), String.format(
                         "Value provided for `%s` must be less than or equal to %s, but found \"%s\"",
                         shape.getId(), trait.getMax().get(), node.getValue()),
                         shape.isMemberShape() ? MEMBER : TARGET);
@@ -70,7 +69,7 @@ class RangeTraitPlugin implements NodeValidatorPlugin {
         });
     }
 
-    protected void check(Shape shape, boolean zeroValueWarning, RangeTrait trait, NumberNode node, Emitter emitter) {
+    protected void check(Shape shape, Context context, RangeTrait trait, NumberNode node, Emitter emitter) {
         Number number = node.getValue();
         BigDecimal decimal = number instanceof BigDecimal
                 ? (BigDecimal) number
@@ -78,7 +77,7 @@ class RangeTraitPlugin implements NodeValidatorPlugin {
 
         trait.getMin().ifPresent(min -> {
             if (decimal.compareTo(new BigDecimal(min.toString())) < 0) {
-                emitter.accept(node, getSeverity(node, zeroValueWarning), String.format(
+                emitter.accept(node, getSeverity(node, context), String.format(
                         "Value provided for `%s` must be greater than or equal to %s, but found %s",
                         shape.getId(), min, number),
                         shape.isMemberShape() ? MEMBER : TARGET);
@@ -87,7 +86,7 @@ class RangeTraitPlugin implements NodeValidatorPlugin {
 
         trait.getMax().ifPresent(max -> {
             if (decimal.compareTo(new BigDecimal(max.toString())) > 0) {
-                emitter.accept(node, getSeverity(node, zeroValueWarning), String.format(
+                emitter.accept(node, getSeverity(node, context), String.format(
                         "Value provided for `%s` must be less than or equal to %s, but found %s",
                         shape.getId(), max, number),
                         shape.isMemberShape() ? MEMBER : TARGET);
@@ -95,7 +94,15 @@ class RangeTraitPlugin implements NodeValidatorPlugin {
         });
     }
 
-    private Severity getSeverity(NumberNode node, boolean zeroValueWarning) {
-        return zeroValueWarning && node.isZero() ? Severity.WARNING : Severity.ERROR;
+    private Severity getSeverity(NumberNode node, Context context) {
+        boolean zeroValueWarning = context
+                .hasFeature(NodeValidationVisitor.Feature.RANGE_TRAIT_ZERO_VALUE_WARNING);
+        boolean rangeTraitWarning = context.hasFeature(NodeValidationVisitor.Feature.ALLOW_CONSTRAINT_ERRORS);
+        return (zeroValueWarning && node.isZero()) || rangeTraitWarning ? Severity.WARNING : Severity.ERROR;
+    }
+
+    private Severity getSeverity(Context context) {
+        return context.hasFeature(NodeValidationVisitor.Feature.ALLOW_CONSTRAINT_ERRORS)
+                ? Severity.WARNING : Severity.ERROR;
     }
 }
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/StringLengthPlugin.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/StringLengthPlugin.java
index ee6c4ee20..cd358a1eb 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/StringLengthPlugin.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/StringLengthPlugin.java
@@ -19,6 +19,8 @@ import software.amazon.smithy.model.node.StringNode;
 import software.amazon.smithy.model.shapes.Shape;
 import software.amazon.smithy.model.shapes.StringShape;
 import software.amazon.smithy.model.traits.LengthTrait;
+import software.amazon.smithy.model.validation.NodeValidationVisitor;
+import software.amazon.smithy.model.validation.Severity;
 
 /**
  * Validates the length trait on string shapes or members that target them.
@@ -33,7 +35,7 @@ final class StringLengthPlugin extends MemberAndShapeTraitPlugin<StringShape, St
     protected void check(Shape shape, LengthTrait trait, StringNode node, Context context, Emitter emitter) {
         trait.getMin().ifPresent(min -> {
             if (node.getValue().length() < min) {
-                emitter.accept(node, String.format(
+                emitter.accept(node, getSeverity(context), String.format(
                         "String value provided for `%s` must be >= %d characters, but the provided value is "
                         + "only %d characters.", shape.getId(), min, node.getValue().length()));
             }
@@ -41,10 +43,15 @@ final class StringLengthPlugin extends MemberAndShapeTraitPlugin<StringShape, St
 
         trait.getMax().ifPresent(max -> {
             if (node.getValue().length() > max) {
-                emitter.accept(node, String.format(
+                emitter.accept(node, getSeverity(context), String.format(
                         "String value provided for `%s` must be <= %d characters, but the provided value is "
                         + "%d characters.", shape.getId(), max, node.getValue().length()));
             }
         });
     }
+
+    private Severity getSeverity(Context context) {
+        return context.hasFeature(NodeValidationVisitor.Feature.ALLOW_CONSTRAINT_ERRORS)
+                ? Severity.WARNING : Severity.ERROR;
+    }
 }
diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/validators/ExamplesTraitValidator.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/validators/ExamplesTraitValidator.java
index 3535faa97..5f4913e3b 100644
--- a/smithy-model/src/main/java/software/amazon/smithy/model/validation/validators/ExamplesTraitValidator.java
+++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/validators/ExamplesTraitValidator.java
@@ -47,15 +47,21 @@ public final class ExamplesTraitValidator extends AbstractValidator {
         List<ExamplesTrait.Example> examples = trait.getExamples();
 
         for (ExamplesTrait.Example example : examples) {
-            model.getShape(shape.getInputShape()).ifPresent(input -> {
-                NodeValidationVisitor validator = createVisitor(
-                        "input", example.getInput(), model, shape, example);
-                events.addAll(input.accept(validator));
-            });
-
             boolean isOutputDefined = example.getOutput().isPresent();
             boolean isErrorDefined = example.getError().isPresent();
 
+            model.getShape(shape.getInputShape()).ifPresent(input -> {
+                NodeValidationVisitor validator;
+                if (example.getAllowConstraintErrors() && !isErrorDefined) {
+                    events.add(error(shape, trait, String.format(
+                            "Example: `%s` has allowConstraintErrors enabled, so error must be defined.",
+                            example.getTitle())));
+                }
+                validator = createVisitor("input", example.getInput(), model, shape, example);
+                List<ValidationEvent> inputValidationEvents = input.accept(validator);
+                events.addAll(inputValidationEvents);
+            });
+
             if (isOutputDefined && isErrorDefined) {
                 events.add(error(shape, trait, String.format(
                         "Example: `%s` has both output and error defined, only one should be present.",
@@ -91,12 +97,15 @@ public final class ExamplesTraitValidator extends AbstractValidator {
             Shape shape,
             ExamplesTrait.Example example
     ) {
-        return NodeValidationVisitor.builder()
+        NodeValidationVisitor.Builder builder = NodeValidationVisitor.builder()
                 .model(model)
                 .eventShapeId(shape.getId())
                 .value(value)
                 .startingContext("Example " + name + " of `" + example.getTitle() + "`")
-                .eventId(getName())
-                .build();
+                .eventId(getName());
+        if (example.getAllowConstraintErrors()) {
+            builder.addFeature(NodeValidationVisitor.Feature.ALLOW_CONSTRAINT_ERRORS);
+        }
+        return builder.build();
     }
 }
diff --git a/smithy-model/src/main/resources/software/amazon/smithy/model/loader/prelude.smithy b/smithy-model/src/main/resources/software/amazon/smithy/model/loader/prelude.smithy
index 5235d6dce..007f60a90 100644
--- a/smithy-model/src/main/resources/software/amazon/smithy/model/loader/prelude.smithy
+++ b/smithy-model/src/main/resources/software/amazon/smithy/model/loader/prelude.smithy
@@ -363,6 +363,8 @@ structure Example {
     output: Document
 
     error: ExampleError
+
+    allowConstraintErrors: Boolean
 }
 
 @private


Test patch:
diff --git a/smithy-aws-protocol-tests/model/restJson1/http-payload.smithy b/smithy-aws-protocol-tests/model/restJson1/http-payload.smithy
index d9f69d2d2..3d11d8888 100644
--- a/smithy-aws-protocol-tests/model/restJson1/http-payload.smithy
+++ b/smithy-aws-protocol-tests/model/restJson1/http-payload.smithy
@@ -10,7 +10,7 @@ use aws.protocoltests.shared#TextPlainBlob
 use smithy.test#httpRequestTests
 use smithy.test#httpResponseTests
 
-/// This examples serializes a blob shape in the payload.
+/// This example serializes a blob shape in the payload.
 ///
 /// In this example, no JSON document is synthesized because the payload is
 /// not a structure or a union type.
@@ -138,7 +138,7 @@ structure HttpPayloadTraitsInputOutput {
     blob: Blob,
 }
 
-/// This examples uses a `@mediaType` trait on the payload to force a custom
+/// This example uses a `@mediaType` trait on the payload to force a custom
 /// content-type to be serialized.
 @http(uri: "/HttpPayloadTraitsWithMediaType", method: "POST")
 operation HttpPayloadTraitsWithMediaType {
@@ -196,7 +196,7 @@ structure HttpPayloadTraitsWithMediaTypeInputOutput {
     blob: TextPlainBlob,
 }
 
-/// This examples serializes a structure in the payload.
+/// This example serializes a structure in the payload.
 ///
 /// Note that serializing a structure changes the wrapper element name
 /// to match the targeted structure.
@@ -268,3 +268,91 @@ structure NestedPayload {
     greeting: String,
     name: String,
 }
+
+/// This example serializes a union in the payload.
+@idempotent
+@http(uri: "/HttpPayloadWithUnion", method: "PUT")
+operation HttpPayloadWithUnion {
+    input: HttpPayloadWithUnionInputOutput,
+    output: HttpPayloadWithUnionInputOutput
+}
+
+apply HttpPayloadWithUnion @httpRequestTests([
+    {
+        id: "RestJsonHttpPayloadWithUnion",
+        documentation: "Serializes a union in the payload.",
+        protocol: restJson1,
+        method: "PUT",
+        uri: "/HttpPayloadWithUnion",
+        body: """
+              {
+                  "greeting": "hello"
+              }""",
+        bodyMediaType: "application/json",
+        headers: {
+            "Content-Type": "application/json"
+        },
+        requireHeaders: [
+            "Content-Length"
+        ],
+        params: {
+            nested: {
+                greeting: "hello"
+            }
+        }
+    },
+    {
+        id: "RestJsonHttpPayloadWithUnsetUnion",
+        documentation: "No payload is sent if the union has no value.",
+        protocol: restJson1,
+        method: "PUT",
+        uri: "/HttpPayloadWithUnion",
+        body: "",
+        headers: {
+            "Content-Length": "0"
+        },
+        params: {}
+    }
+])
+
+apply HttpPayloadWithUnion @httpResponseTests([
+    {
+        id: "RestJsonHttpPayloadWithUnion",
+        documentation: "Serializes a union in the payload.",
+        protocol: restJson1,
+        code: 200,
+        body: """
+              {
+                  "greeting": "hello"
+              }""",
+        bodyMediaType: "application/json",
+        headers: {
+            "Content-Type": "application/json"
+        },
+        params: {
+            nested: {
+                greeting: "hello"
+            }
+        }
+    },
+    {
+        id: "RestJsonHttpPayloadWithUnsetUnion",
+        documentation: "No payload is sent if the union has no value.",
+        protocol: restJson1,
+        code: 200,
+        body: "",
+        headers: {
+            "Content-Length": "0"
+        },
+        params: {}
+    }
+])
+
+structure HttpPayloadWithUnionInputOutput {
+    @httpPayload
+    nested: UnionPayload,
+}
+
+union UnionPayload {
+    greeting: String
+}
diff --git a/smithy-aws-protocol-tests/model/restJson1/main.smithy b/smithy-aws-protocol-tests/model/restJson1/main.smithy
index 20a803411..25b59bf8a 100644
--- a/smithy-aws-protocol-tests/model/restJson1/main.smithy
+++ b/smithy-aws-protocol-tests/model/restJson1/main.smithy
@@ -58,6 +58,7 @@ service RestJson {
         HttpPayloadWithStructure,
         HttpEnumPayload,
         HttpStringPayload,
+        HttpPayloadWithUnion,
 
         // @httpResponseCode tests
         HttpResponseCode,
diff --git a/smithy-aws-protocol-tests/model/restXml/http-payload.smithy b/smithy-aws-protocol-tests/model/restXml/http-payload.smithy
index c63a0b077..4713d36f8 100644
--- a/smithy-aws-protocol-tests/model/restXml/http-payload.smithy
+++ b/smithy-aws-protocol-tests/model/restXml/http-payload.smithy
@@ -10,7 +10,7 @@ use aws.protocoltests.shared#TextPlainBlob
 use smithy.test#httpRequestTests
 use smithy.test#httpResponseTests
 
-/// This examples serializes a blob shape in the payload.
+/// This example serializes a blob shape in the payload.
 ///
 /// In this example, no XML document is synthesized because the payload is
 /// not a structure or a union type.
@@ -93,7 +93,7 @@ structure HttpPayloadTraitsInputOutput {
     blob: Blob,
 }
 
-/// This examples uses a `@mediaType` trait on the payload to force a custom
+/// This example uses a `@mediaType` trait on the payload to force a custom
 /// content-type to be serialized.
 @http(uri: "/HttpPayloadTraitsWithMediaType", method: "POST")
 operation HttpPayloadTraitsWithMediaType {
@@ -149,7 +149,7 @@ structure HttpPayloadTraitsWithMediaTypeInputOutput {
     blob: TextPlainBlob,
 }
 
-/// This examples serializes a structure in the payload.
+/// This example serializes a structure in the payload.
 ///
 /// Note that serializing a structure changes the wrapper element name
 /// to match the targeted structure.
@@ -530,3 +530,93 @@ structure HttpPayloadWithXmlNamespaceAndPrefixInputOutput {
 structure PayloadWithXmlNamespaceAndPrefix {
     name: String
 }
+
+
+/// This example serializes a union in the payload.
+@idempotent
+@http(uri: "/HttpPayloadWithUnion", method: "PUT")
+operation HttpPayloadWithUnion {
+    input: HttpPayloadWithUnionInputOutput,
+    output: HttpPayloadWithUnionInputOutput
+}
+
+apply HttpPayloadWithUnion @httpRequestTests([
+    {
+        id: "RestXmlHttpPayloadWithUnion",
+        documentation: "Serializes a union in the payload.",
+        protocol: restXml,
+        method: "PUT",
+        uri: "/HttpPayloadWithUnion",
+        body: """
+              <UnionPayload>
+                  <greeting>hello</greeting>
+              </UnionPayload>""",
+        bodyMediaType: "application/xml",
+        headers: {
+            "Content-Type": "application/xml",
+        },
+        requireHeaders: [
+            "Content-Length"
+        ],
+        params: {
+            nested: {
+                greeting: "hello"
+            }
+        }
+    },
+    {
+        id: "RestXmlHttpPayloadWithUnsetUnion",
+        documentation: "No payload is sent if the union has no value.",
+        protocol: restXml,
+        method: "PUT",
+        uri: "/HttpPayloadWithUnion",
+        body: "",
+        headers: {
+            "Content-Type": "application/xml",
+            "Content-Length": "0"
+        },
+        params: {}
+    }
+])
+
+apply HttpPayloadWithUnion @httpResponseTests([
+    {
+        id: "RestXmlHttpPayloadWithUnion",
+        documentation: "Serializes a union in the payload.",
+        protocol: restXml,
+        code: 200,
+        body: """
+              <UnionPayload>
+                  <greeting>hello</greeting>
+              </UnionPayload>""",
+        bodyMediaType: "application/xml",
+        headers: {
+            "Content-Type": "application/xml",
+        },
+        params: {
+            nested: {
+                greeting: "hello"
+            }
+        }
+    },
+    {
+        id: "RestXmlHttpPayloadWithUnsetUnion",
+        documentation: "No payload is sent if the union has no value.",
+        protocol: restXml,
+        code: 200,
+        body: "",
+        headers: {
+            "Content-Length": "0"
+        },
+        params: {}
+    }
+])
+
+structure HttpPayloadWithUnionInputOutput {
+    @httpPayload
+    nested: UnionPayload,
+}
+
+union UnionPayload {
+    greeting: String
+}
diff --git a/smithy-aws-protocol-tests/model/restXml/main.smithy b/smithy-aws-protocol-tests/model/restXml/main.smithy
index 25a7ed781..f0ecc623f 100644
--- a/smithy-aws-protocol-tests/model/restXml/main.smithy
+++ b/smithy-aws-protocol-tests/model/restXml/main.smithy
@@ -50,6 +50,7 @@ service RestXml {
         HttpPayloadTraits,
         HttpPayloadTraitsWithMediaType,
         HttpPayloadWithStructure,
+        HttpPayloadWithUnion,
         HttpPayloadWithXmlName,
         BodyWithXmlName,
         HttpPayloadWithMemberXmlName,
diff --git a/smithy-model/src/test/java/software/amazon/smithy/model/traits/ExamplesTraitTest.java b/smithy-model/src/test/java/software/amazon/smithy/model/traits/ExamplesTraitTest.java
index dfbcca76e..19dc1ab68 100644
--- a/smithy-model/src/test/java/software/amazon/smithy/model/traits/ExamplesTraitTest.java
+++ b/smithy-model/src/test/java/software/amazon/smithy/model/traits/ExamplesTraitTest.java
@@ -40,7 +40,8 @@ public class ExamplesTraitTest {
                         .withMember("output", Node.objectNode().withMember("c", Node.from("d")))
                         .withMember("error", Node.objectNode()
                                 .withMember(Node.from("shapeId"), Node.from("smithy.example#FooError"))
-                                .withMember(Node.from("content"), Node.objectNode().withMember("e", Node.from("f")))));
+                                .withMember(Node.from("content"), Node.objectNode().withMember("e", Node.from("f"))))
+                        .withMember("allowConstraintErrors", Node.from(true)));
 
         Optional<Trait> trait = provider.createTrait(
                 ShapeId.from("smithy.api#examples"), ShapeId.from("ns.qux#foo"), node);
diff --git a/smithy-model/src/test/java/software/amazon/smithy/model/validation/ExamplesTraitValidatorTest.java b/smithy-model/src/test/java/software/amazon/smithy/model/validation/ExamplesTraitValidatorTest.java
index 3971fe696..daf056fe5 100644
--- a/smithy-model/src/test/java/software/amazon/smithy/model/validation/ExamplesTraitValidatorTest.java
+++ b/smithy-model/src/test/java/software/amazon/smithy/model/validation/ExamplesTraitValidatorTest.java
@@ -1,11 +1,16 @@
 package software.amazon.smithy.model.validation;
 
+import java.util.List;
 import org.junit.jupiter.api.Test;
 import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.node.Node;
+import software.amazon.smithy.model.shapes.MemberShape;
 import software.amazon.smithy.model.shapes.OperationShape;
 import software.amazon.smithy.model.shapes.Shape;
+import software.amazon.smithy.model.shapes.StructureShape;
 import software.amazon.smithy.model.traits.ExamplesTrait;
 import software.amazon.smithy.model.validation.validators.ExamplesTraitValidator;
+import software.amazon.smithy.utils.ListUtils;
 
 public class ExamplesTraitValidatorTest {
     // There was an NPE previously due to the input/output values
diff --git a/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/examples-trait-validator.errors b/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/examples-trait-validator.errors
index 51d9f2128..6d971e8b1 100644
--- a/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/examples-trait-validator.errors
+++ b/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/examples-trait-validator.errors
@@ -6,3 +6,17 @@
 [WARNING] ns.foo#Operation: Example output of `Testing 2`: Invalid structure member `additional` found for `ns.foo#OperationOutput` | ExamplesTrait
 [ERROR] ns.foo#Operation: Example output of `Testing 2`: Missing required structure member `bam` for `ns.foo#OperationOutput` | ExamplesTrait
 [WARNING] ns.foo#Operation: Example error of `Testing 1`: Invalid structure member `extra` found for `ns.foo#OperationError` | ExamplesTrait
+[ERROR] ns.foo#Operation: Example: `Testing 4` has allowConstraintErrors enabled, so error must be defined. | ExamplesTrait
+[WARNING] ns.foo#Operation: Example input of `Testing 5`.blobMin: Value provided for `ns.foo#OperationInput$blobMin` must have at least 3 bytes, but the provided value only has 1 bytes | ExamplesTrait
+[WARNING] ns.foo#Operation: Example input of `Testing 5`.blobMax: Value provided for `ns.foo#OperationInput$blobMax` must have no more than 3 bytes, but the provided value has 6 bytes | ExamplesTrait
+[WARNING] ns.foo#Operation: Example input of `Testing 5`: Missing required structure member `foo` for `ns.foo#OperationInput` | ExamplesTrait
+[WARNING] ns.foo#Operation: Example input of `Testing 5`.mapMin: Value provided for `ns.foo#OperationInput$mapMin` must have at least 3 entries, but the provided value only has 1 entries | ExamplesTrait
+[WARNING] ns.foo#Operation: Example input of `Testing 5`.mapMax: Value provided for `ns.foo#OperationInput$mapMax` must have no more than 1 entries, but the provided value has 2 entries | ExamplesTrait
+[WARNING] ns.foo#Operation: Example input of `Testing 5`.pattern: String value provided for `ns.foo#OperationInput$pattern` must match regular expression: ^[a-z]$ | ExamplesTrait
+[WARNING] ns.foo#Operation: Example input of `Testing 5`.rangeMin: Value provided for `ns.foo#OperationInput$rangeMin` must be greater than or equal to 2, but found 1 | ExamplesTrait.Member
+[WARNING] ns.foo#Operation: Example input of `Testing 5`.rangeMax: Value provided for `ns.foo#OperationInput$rangeMax` must be less than or equal to 8, but found 10 | ExamplesTrait.Member
+[WARNING] ns.foo#Operation: Example input of `Testing 5`.rangeNaN: Value provided for `ns.foo#OperationInput$rangeNaN` must be a number because the `smithy.api#range` trait is applied, but found "NaN" | ExamplesTrait
+[WARNING] ns.foo#Operation: Example input of `Testing 5`.rangeNegativeInfinity: Value provided for `ns.foo#OperationInput$rangeNegativeInfinity` must be greater than or equal to 1, but found "-Infinity" | ExamplesTrait.Member
+[WARNING] ns.foo#Operation: Example input of `Testing 5`.rangePositiveInfinity: Value provided for `ns.foo#OperationInput$rangePositiveInfinity` must be less than or equal to 2, but found "Infinity" | ExamplesTrait.Member
+[WARNING] ns.foo#Operation: Example input of `Testing 5`.stringMin: String value provided for `ns.foo#OperationInput$stringMin` must be >= 3 characters, but the provided value is only 1 characters. | ExamplesTrait
+[WARNING] ns.foo#Operation: Example input of `Testing 5`.stringMax: String value provided for `ns.foo#OperationInput$stringMax` must be <= 3 characters, but the provided value is 6 characters. | ExamplesTrait
diff --git a/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/examples-trait-validator.json b/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/examples-trait-validator.json
index 64583c973..3c6623d15 100644
--- a/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/examples-trait-validator.json
+++ b/smithy-model/src/test/resources/software/amazon/smithy/model/errorfiles/validators/examples-trait-validator.json
@@ -53,6 +53,45 @@
                                 "bat": "baz"
                             }
                         }
+                    },
+                    {
+                        "title": "Testing 4",
+                        "input": {
+                            "foo": "baz"
+                        },
+                        "output": {
+                            "bam": "value3"
+                        },
+                        "allowConstraintErrors": true
+                    },
+                    {
+                        "title": "Testing 5",
+                        "input": {
+                            "blobMin": "a",
+                            "blobMax": "abcdef",
+                            "mapMin": {
+                                "a": "b"
+                            },
+                            "mapMax": {
+                                "a": "b",
+                                "b": "c"
+                            },
+                            "pattern": "123",
+                            "rangeMin": 1,
+                            "rangeMax": 10,
+                            "rangeNaN": "NaN",
+                            "rangeNegativeInfinity": "-Infinity",
+                            "rangePositiveInfinity": "Infinity",
+                            "stringMin": "a",
+                            "stringMax": "abcdef"
+                        },
+                        "error": {
+                            "shapeId": "ns.foo#OperationError",
+                            "content": {
+                                "bat": "baz"
+                            }
+                        },
+                        "allowConstraintErrors": true
                     }
                 ]
             }
@@ -79,6 +118,101 @@
                 },
                 "baz": {
                     "target": "ns.foo#String"
+                },
+                "blobMin": {
+                    "target": "ns.foo#Blob",
+                    "traits": {
+                        "smithy.api#length": {
+                            "min": 3
+                        }
+                    }
+                },
+                "blobMax": {
+                    "target": "ns.foo#Blob",
+                    "traits": {
+                        "smithy.api#length": {
+                            "max": 3
+                        }
+                    }
+                },
+                "mapMin": {
+                    "target": "ns.foo#Map",
+                    "traits": {
+                        "smithy.api#length": {
+                            "min": 3
+                        }
+                    }
+                },
+                "mapMax": {
+                    "target": "ns.foo#Map",
+                    "traits": {
+                        "smithy.api#length": {
+                            "max": 1
+                        }
+                    }
+                },
+                "pattern": {
+                    "target": "ns.foo#String",
+                    "traits": {
+                        "smithy.api#pattern": "^[a-z]$"
+                    }
+                },
+                "rangeMin": {
+                    "target": "ns.foo#Integer",
+                    "traits": {
+                        "smithy.api#range": {
+                            "min": 2
+                        }
+                    }
+                },
+                "rangeMax": {
+                    "target": "ns.foo#Integer",
+                    "traits": {
+                        "smithy.api#range": {
+                            "max": 8
+                        }
+                    }
+                },
+                "rangeNaN": {
+                    "target": "ns.foo#Float",
+                    "traits": {
+                        "smithy.api#range": {
+                            "min": 1,
+                            "max": 2
+                        }
+                    }
+                },
+                "rangeNegativeInfinity": {
+                    "target": "ns.foo#Float",
+                    "traits": {
+                        "smithy.api#range": {
+                            "min": 1
+                        }
+                    }
+                },
+                "rangePositiveInfinity": {
+                    "target": "ns.foo#Float",
+                    "traits": {
+                        "smithy.api#range": {
+                            "max": 2
+                        }
+                    }
+                },
+                "stringMin": {
+                    "target": "ns.foo#String",
+                    "traits": {
+                        "smithy.api#length": {
+                            "min": 3
+                        }
+                    }
+                },
+                "stringMax": {
+                    "target": "ns.foo#String",
+                    "traits": {
+                        "smithy.api#length": {
+                            "max": 3
+                        }
+                    }
                 }
             },
             "traits": {
@@ -127,6 +261,24 @@
         },
         "ns.foo#String": {
             "type": "string"
+        },
+        "ns.foo#Blob": {
+            "type": "blob"
+        },
+        "ns.foo#Map": {
+            "type": "map",
+            "key": {
+                "target": "smithy.api#String"
+            },
+            "value": {
+                "target": "smithy.api#String"
+            }
+        },
+        "ns.foo#Integer": {
+            "type": "integer"
+        },
+        "ns.foo#Float": {
+            "type": "float"
         }
     }
 }