Skip to content

Add new shapeExamples trait that communicates and enforces allowed and disallowed values#2851

Open
brandondahler wants to merge 3 commits intosmithy-lang:mainfrom
brandondahler:features/ShapeExamples
Open

Add new shapeExamples trait that communicates and enforces allowed and disallowed values#2851
brandondahler wants to merge 3 commits intosmithy-lang:mainfrom
brandondahler:features/ShapeExamples

Conversation

@brandondahler
Copy link
Contributor

Background

  • What do these changes do?
    • Adds a new @shapeExamples trait which defines the set of allowed and disallowed values for a given shape.
    • Updates the NodeValidationVisitor to be more accurate in some less-commonly used cases
    • Adds a new ShapeExamplesTraitValidator implementation which verifies that:
      1. Allowed values are accepted by the shape based on the configured constraints
      2. Disallowed values are not accepted by the shape based on the configured constraints
  • Why are they important?
    • Configuring and modifying a shape's constraints is tedious work with potentially large consequences if mistakes are made.
    • This is especially true for @pattern-based constrained values
    • This feature offers a mechanism to both express your intent as a shape author about what you meant to configure while also enforcing that the intent is a reality during the model validation phase.

Testing

  • How did you test these changes?
    • Significant effort was put into building out an ErrorFilesTest-based suite of tests to ensure that the validation works across as much of the gamut of constraint and shape type combinations that exist.
    • Documentation updates were best effort -- I do not have an environment setup that is readily capable of building and inspecting the output.

Links


By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@brandondahler brandondahler requested a review from a team as a code owner November 13, 2025 03:49
@brandondahler brandondahler requested a review from yefrig November 13, 2025 03:49
…3a2f142a.json

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
}
}

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.

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.

@robin-aws robin-aws mentioned this pull request Dec 5, 2025
Comment on lines +778 to +779
allowed: ShapeExampleList
disallowed: ShapeExampleList
Copy link
Contributor

Choose a reason for hiding this comment

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

nit:

Suggested change
allowed: ShapeExampleList
disallowed: ShapeExampleList
valid: ShapeExampleList
invalid: ShapeExampleList

AFAICT "valid" appears a whole lot more in the spec/documentation than "allowed"

Copy link
Contributor

@JordonPhillips JordonPhillips left a comment

Choose a reason for hiding this comment

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

I talked to the SDK teams and got some general consensus on bringing this in. There's a few changes I'd like to make, notably adding some optional documentation to each example to explain why something does or doesn't pass.

Comment on lines +774 to +776
@trait(
selector: ":test(number, string, blob, structure, list, map, member)"
)
Copy link
Contributor

Choose a reason for hiding this comment

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

This should be able to target unions. Also, gotta make sure to restrict the target for member shapes.

Suggested change
@trait(
selector: ":test(number, string, blob, structure, list, map, member)"
)
@trait(
selector: """
:test(
number, string, blob, structure, union, list, map,
member > :test(number, string, blob, structure, union, list, map)
)"""
)

Comment on lines +782 to +787
@private
@length(min: 1)
@sparse
list ShapeExampleList {
member: Document
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd like to open this up a bit by making the list value a structure. That'll let us add additional properties aside from the value itself. At the moment I've added a documentation property to describe why a given value does or does not validate. There was also some desire to add a specific error that invalid values trigger. I'm not so sure about that. But opening it up like this makes that possible too.

Suggested change
@private
@length(min: 1)
@sparse
list ShapeExampleList {
member: Document
}
/// A list of values to be mapped to a shape.
@private
@length(min: 1)
@sparse
list ShapeExampleList {
member: ShapeExample
}
/// A value that may be mapped to a shape.
@private
structure ShapeExample {
/// The actual value that may be mapped to the shape. This must match the
/// expected shape type, regardless of whether this example represents a
/// valid or invalid value.
@required
value: Document
/// Optional documentation in CommonMark format describing why the example
/// does or does not match the shape's constraints.
documentation: String
}

Copy link
Contributor

Choose a reason for hiding this comment

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

+1. As the person with the desire mentioned :), I think it would be great to also have a validationEventId member stating precisely what event is expected, using the same matching rules as suppressions. That would enable the validation of this trait much more precise.


events.addAll(nonErrorValidationEvents);

if (validationEvents.size() == nonErrorValidationEvents.size()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think it's useful to allow examples that don't match the expected type, even for the invalid list. Perhaps we should forward that along.

Copy link
Contributor

Choose a reason for hiding this comment

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

If you take my suggestion (https://github.com/smithy-lang/smithy/pull/2851/changes#r2834425764), this could be more precise and check that you only get events that match the expected id.

Comment on lines +82 to +91
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;
Copy link
Contributor

Choose a reason for hiding this comment

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

Using BuilderRef prevents leaking mutations and reduces copies.

Suggested change
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;
private BuilderRef<List<Node>> allowed = BuilderRef.forList();
private BuilderRef<List<Node>> disallowed = BuilderRef.forList();
public ShapeExamplesTrait.Builder allowed(List<Node> allowed) {
this.allowed.clear();
this.allowed.get().addAll(allowed);
return this;
}
public ShapeExamplesTrait.Builder disallowed(List<Node> disallowed) {
this.disallowed.clear();
this.disallowed.addAll(disallowed);

Comment on lines +28 to +38
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());
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Empty lists are perfectly fine, so long as at least one is populated.

Suggested change
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());
}
this.allowed = builder.allowed.copy();
this.disallowed = builder.disallowed.copy();
if (allowed.isEmpty() && disallowed.isEmpty()) {
throw new SourceException("One of 'allowed' or 'disallowed' must be non-empty.", getSourceLocation());
}

Comment on lines +46 to +57
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);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
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);
}
public List<Node> getAllowed() {
return allowed;
}
/**
* Gets the disallowed values.
*
* @return returns the optional disallowed values.
*/
public List<Node> getDisallowed() {
return disallowed;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants