Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "feature",
"description": "Add new shapeExamples to express allowed and disallowed values on an individual shape level",
"pull_requests": [
"[#2851](https://github.com/smithy-lang/smithy/pull/2851)"
]
}
49 changes: 49 additions & 0 deletions docs/source-2.0/spec/documentation-traits.rst
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,55 @@ Value type
``string`` representing the date it was added.


.. smithy-trait:: smithy.api#shapeExamples
.. _shapeExamples-trait:

``shapeExamples`` trait
=======================

Summary
Defines values which are specifically allowed and/or disallowed for a
shape.

These shape example values are validated within the model to ensure there
is consistency between the shape author's intent and the shape's configured
:doc:`constraint traits <constraint-traits>`.
Trait selector
``:test(number, string, blob, structure, list, map, member)``
Value type
Structure with the following members:

.. list-table::
:header-rows: 1
:widths: 10 10 80

* - Property
- Type
- Description
* - allowed
- ``[document]``
- Provides a list of values which are explicitly valid per the
shape's definition.
* - disallowed
- ``[document]```
- Provides a list of values which are explicitly invalid per the
shape's definition.

One of either ``allowed`` or ``disallowed`` MUST be provided. When ``allowed``
or ``disallowed`` is defined, it MUST have at least one value.

.. tabs::

.. code-tab:: smithy

@shapeExamples({
allowed: ["a"]
disallowed: ["aa"]
})
@length(min: 1, max: 1)
string MyString


.. smithy-trait:: smithy.api#tags
.. _tags-trait:

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package software.amazon.smithy.model.traits;

import java.util.List;
import java.util.Optional;
import software.amazon.smithy.model.SourceException;
import software.amazon.smithy.model.node.ArrayNode;
import software.amazon.smithy.model.node.Node;
import software.amazon.smithy.model.node.ObjectNode;
import software.amazon.smithy.model.shapes.ShapeId;
import software.amazon.smithy.utils.MapUtils;
import software.amazon.smithy.utils.ToSmithyBuilder;

/**
* Defines values which are specifically allowed and/or disallowed for a shape.
*/
public final class ShapeExamplesTrait extends AbstractTrait implements ToSmithyBuilder<ShapeExamplesTrait> {
public static final ShapeId ID = ShapeId.from("smithy.api#shapeExamples");

private final List<Node> allowed;
private final List<Node> disallowed;

private ShapeExamplesTrait(ShapeExamplesTrait.Builder builder) {
super(ID, builder.sourceLocation);
this.allowed = builder.allowed;
this.disallowed = builder.disallowed;
if (allowed == null && disallowed == null) {
throw new SourceException("One of 'allowed' or 'disallowed' must be provided.", getSourceLocation());
}
if (allowed != null && allowed.isEmpty()) {
throw new SourceException("'allowed' must be non-empty when provided.", getSourceLocation());
}
if (disallowed != null && disallowed.isEmpty()) {
throw new SourceException("'disallowed' must be non-empty when provided.", getSourceLocation());
}
}

/**
* Gets the allowed values.
*
* @return returns the optional allowed values.
*/
public Optional<List<Node>> getAllowed() {
return Optional.ofNullable(allowed);
}

/**
* Gets the disallowed values.
*
* @return returns the optional disallowed values.
*/
public Optional<List<Node>> getDisallowed() {
return Optional.ofNullable(disallowed);
}

@Override
protected Node createNode() {
return new ObjectNode(MapUtils.of(), getSourceLocation())
.withOptionalMember("allowed", getAllowed().map(ArrayNode::fromNodes))
.withOptionalMember("disallowed", getDisallowed().map(ArrayNode::fromNodes));
}

@Override
public ShapeExamplesTrait.Builder toBuilder() {
return builder().allowed(allowed).disallowed(disallowed).sourceLocation(getSourceLocation());
}

/**
* @return Returns a new ShapeExamplesTrait builder.
*/
public static ShapeExamplesTrait.Builder builder() {
return new ShapeExamplesTrait.Builder();
}

/**
* Builder used to create a ShapeExamplesTrait.
*/
public static final class Builder extends AbstractTraitBuilder<ShapeExamplesTrait, ShapeExamplesTrait.Builder> {
private List<Node> allowed;
private List<Node> disallowed;

public ShapeExamplesTrait.Builder allowed(List<Node> allowed) {
this.allowed = allowed;
return this;
}

public ShapeExamplesTrait.Builder disallowed(List<Node> disallowed) {
this.disallowed = disallowed;
return this;
}

@Override
public ShapeExamplesTrait build() {
return new ShapeExamplesTrait(this);
}
}

public static final class Provider implements TraitService {
@Override
public ShapeId getShapeId() {
return ID;
}

@Override
public ShapeExamplesTrait createTrait(ShapeId target, Node value) {
ShapeExamplesTrait.Builder builder = builder().sourceLocation(value.getSourceLocation());
value.expectObjectNode()
.getMember("allowed", ShapeExamplesTrait.Provider::convertToShapeExampleList, builder::allowed)
.getMember("disallowed",
ShapeExamplesTrait.Provider::convertToShapeExampleList,
builder::disallowed);
ShapeExamplesTrait result = builder.build();
result.setNodeCache(value);
return result;
}

private static List<Node> convertToShapeExampleList(Node node) {
return node.expectArrayNode().getElements();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,9 @@ public List<ValidationEvent> unionShape(UnionShape shape) {
return value.asObjectNode()
.map(object -> {
List<ValidationEvent> events = applyPlugins(shape);
if (object.size() > 1) {
if (object.isEmpty()) {
events.add(event("union values must contain a value for exactly one member"));
} else if (object.size() > 1) {
events.add(event("union values can contain a value for only a single member"));
} else {
Map<String, MemberShape> members = shape.getAllMembers();
Expand All @@ -395,16 +397,19 @@ public List<ValidationEvent> unionShape(UnionShape shape) {
@Override
public List<ValidationEvent> memberShape(MemberShape shape) {
List<ValidationEvent> events = applyPlugins(shape);
if (value.isNullNode()) {

if (!value.isNullNode()) {
model.getShape(shape.getTarget()).ifPresent(target -> {
Copy link
Contributor Author

@brandondahler brandondahler Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Originally traversing into the shape when the value was null pretty much guaranteed that an error would be reported -- generally of the form "<string, number, object> was expected but value was null" (not exact wording).

Since is the memberShape method, this only affects aggregate types which have an explicit null value as opposed to the member being omitted.

// We only need to keep track of a single referring member, so a stack of members or anything like that
// isn't needed here.
validationContext.setReferringMember(shape);
events.addAll(target.accept(this));
validationContext.setReferringMember(null);
});
} else {
events.addAll(checkNullMember(shape));
}
model.getShape(shape.getTarget()).ifPresent(target -> {
// We only need to keep track of a single referring member, so a stack of members or anything like that
// isn't needed here.
validationContext.setReferringMember(shape);
events.addAll(target.accept(this));
validationContext.setReferringMember(null);
});

return events;
}

Expand All @@ -421,11 +426,22 @@ public List<ValidationEvent> checkNullMember(MemberShape shape) {
String.format(
"Non-sparse map shape `%s` cannot contain null values",
shape.getContainer())));
case SET:
return ListUtils.of(event(
String.format(
"Set shape `%s` cannot contain null values",
shape.getContainer())));
case STRUCTURE:
return ListUtils.of(event(
String.format("Required structure member `%s` for `%s` cannot be null",
shape.getMemberName(),
shape.getContainer())));
case UNION:
return ListUtils.of(event(
String.format(
"Union member `%s` for `%s` cannot contain null values",
shape.getMemberName(),
shape.getContainer())));
default:
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ protected void check(Shape shape, LengthTrait trait, StringNode node, Context co
byte[] value = node.getValue().getBytes(StandardCharsets.UTF_8);

if (context.hasFeature(NodeValidationVisitor.Feature.REQUIRE_BASE_64_BLOB_VALUES)) {
value = Base64.getDecoder().decode(value);
try {
value = Base64.getDecoder().decode(value);
} catch (IllegalArgumentException e) {
// Error will reported by the blobShape method in NodeValidationVisitor
return;
}
}

int size = value.length;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import software.amazon.smithy.model.validation.Severity;
import software.amazon.smithy.model.validation.ValidatedResult;
import software.amazon.smithy.model.validation.ValidationEvent;
import software.amazon.smithy.model.validation.ValidationEventFormatter;
import software.amazon.smithy.model.validation.Validator;
import software.amazon.smithy.utils.IoUtils;

Expand All @@ -33,6 +34,8 @@ public final class SmithyTestCase {
private static final Pattern EVENT_PATTERN = Pattern.compile(
"^\\[(?<severity>SUPPRESSED|NOTE|WARNING|DANGER|ERROR)] (?<shape>[^ ]+): ?(?<message>.*) \\| (?<id>[^)]+)");

private static final ValidationEventFormatter VALIDATION_EVENT_FORMATTER = new ErrorsFileValidationEventFormatter();

private final List<ValidationEvent> expectedEvents;
private final String modelLocation;

Expand Down Expand Up @@ -222,7 +225,7 @@ public String toString() {
builder.append("\nDid not match the following events\n"
+ "----------------------------------\n");
for (ValidationEvent event : getUnmatchedEvents()) {
builder.append(event.toString().replace("\n", "\\n")).append('\n');
builder.append(VALIDATION_EVENT_FORMATTER.format(event)).append('\n');
}
builder.append('\n');
}
Expand All @@ -231,7 +234,7 @@ public String toString() {
builder.append("\nEncountered unexpected events\n"
+ "-----------------------------\n");
for (ValidationEvent event : getExtraEvents()) {
builder.append(event.toString().replace("\n", "\\n")).append("\n");
builder.append(VALIDATION_EVENT_FORMATTER.format(event)).append("\n");
}
builder.append('\n');
}
Expand Down Expand Up @@ -295,4 +298,25 @@ public static final class Error extends RuntimeException {
this.result = result;
}
}

private static final class ErrorsFileValidationEventFormatter implements ValidationEventFormatter {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated implementation here to make it easier to iterate on error files -- the previous implementation would output the extra file location information and hints if they exist, which are explicitly omitted from the errors file normally.

You can now copy+paste directly from the failure message in the test result into the errors file.

@Override
public String format(ValidationEvent event) {
String message = event.getMessage();

String reason = event.getSuppressionReason().orElse(null);
if (reason != null) {
message += " (" + reason + ")";
}

String formattedEventString = String.format(
"[%s] %s: %s | %s",
event.getSeverity(),
event.getShapeId().map(ShapeId::toString).orElse("-"),
message,
event.getId());

return formattedEventString.replace("\n", "\\n");
}
}
}
Loading