-
Notifications
You must be signed in to change notification settings - Fork 32
Introduce custom constraints to extend the smithy validation rules #1171
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6c12e6d
e29b0e7
3163773
9c1f917
f815d7a
1993dbd
4a537b1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| /* | ||
| * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| package software.amazon.smithy.java.core.schema; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| /** | ||
| * A custom constraint that extends Validation API. | ||
| * | ||
| * <p>Implementations are discovered via {@link java.util.ServiceLoader} and must be registered in | ||
| * {@code META-INF/services/software.amazon.smithy.java.core.schema.CustomConstraint}. | ||
| * | ||
| * @see Validator | ||
| * @see Validator.CustomConstraintProvider | ||
| * @see ValidationError.CustomValidationFailure | ||
| */ | ||
| public interface CustomConstraint { | ||
| /** | ||
| * Determines whether this rule applies to the given schema. | ||
| * | ||
| * @param schema the schema to check | ||
| * @return {@code true} if this rule should validate values of this schema | ||
| */ | ||
| boolean appliesTo(Schema schema); | ||
|
|
||
| /** | ||
| * Validates the given value against this custom rule. | ||
| * | ||
| * @param schema the schema of the value being validated | ||
| * @param value the value to validate | ||
| * @param path the path to the value (e.g., "/user/address/zipCode") | ||
| * @return a list of validation errors, or an empty list if validation passes | ||
| */ | ||
| List<ValidationError> validate(Schema schema, Object value, String path); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -11,6 +11,7 @@ | |||||
| import java.time.Instant; | ||||||
| import java.util.ArrayList; | ||||||
| import java.util.List; | ||||||
| import java.util.ServiceLoader; | ||||||
| import java.util.function.BiConsumer; | ||||||
| import software.amazon.smithy.java.core.serde.ListSerializer; | ||||||
| import software.amazon.smithy.java.core.serde.MapSerializer; | ||||||
|
|
@@ -224,6 +225,7 @@ public void writeStruct(Schema schema, SerializableStruct struct) { | |||||
| case UNION -> ValidatorOfUnion.validate(this, schema, struct); | ||||||
| default -> checkType(schema, ShapeType.STRUCTURE); // this is guaranteed to fail type checking. | ||||||
| } | ||||||
| applyCustomConstraints(schema, struct); | ||||||
| currentSchema = previousSchema; | ||||||
| elementCount = previousCount; | ||||||
| } | ||||||
|
|
@@ -260,6 +262,7 @@ public <T> void writeList(Schema schema, T state, int size, BiConsumer<T, ShapeS | |||||
|
|
||||||
| checkListLength(schema, count); | ||||||
| } | ||||||
| applyCustomConstraints(schema, state); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Technically, state passed to writeList and writeMap don't have to be the list/map being written (e.g., it could be streaming, or just canned content). The "state" value just acts as a carrier to avoid needing a closure that references up-values. That makes custom list and map validation tricky. |
||||||
| } | ||||||
|
|
||||||
| private void checkListLength(Schema schema, int count) { | ||||||
|
|
@@ -297,6 +300,7 @@ public <T> void writeMap(Schema schema, T state, int size, BiConsumer<T, MapSeri | |||||
| elementCount = previousCount; | ||||||
| checkMapLength(schema, count); | ||||||
| } | ||||||
| applyCustomConstraints(schema, state); | ||||||
| } | ||||||
|
|
||||||
| private void checkMapLength(Schema schema, int count) { | ||||||
|
|
@@ -328,18 +332,21 @@ public <T> void writeEntry( | |||||
| @Override | ||||||
| public void writeBoolean(Schema schema, boolean value) { | ||||||
| checkType(schema, ShapeType.BOOLEAN); | ||||||
| applyCustomConstraints(schema, value); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
| public void writeByte(Schema schema, byte value) { | ||||||
| checkType(schema, ShapeType.BYTE); | ||||||
| validateRange(schema, value, schema.minLongConstraint, schema.maxLongConstraint); | ||||||
| applyCustomConstraints(schema, value); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
| public void writeShort(Schema schema, short value) { | ||||||
| checkType(schema, ShapeType.SHORT); | ||||||
| validateRange(schema, value, schema.minLongConstraint, schema.maxLongConstraint); | ||||||
| applyCustomConstraints(schema, value); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
|
|
@@ -354,24 +361,28 @@ public void writeInteger(Schema schema, int value) { | |||||
| } | ||||||
| default -> checkType(schema, ShapeType.INTEGER); // it's invalid. | ||||||
| } | ||||||
| applyCustomConstraints(schema, value); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
| public void writeLong(Schema schema, long value) { | ||||||
| checkType(schema, ShapeType.LONG); | ||||||
| validateRange(schema, value, schema.minLongConstraint, schema.maxLongConstraint); | ||||||
| applyCustomConstraints(schema, value); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
| public void writeFloat(Schema schema, float value) { | ||||||
| checkType(schema, ShapeType.FLOAT); | ||||||
| validateRange(schema, value, schema.minDoubleConstraint, schema.maxDoubleConstraint); | ||||||
| applyCustomConstraints(schema, value); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
| public void writeDouble(Schema schema, double value) { | ||||||
| checkType(schema, ShapeType.DOUBLE); | ||||||
| validateRange(schema, value, schema.minDoubleConstraint, schema.maxDoubleConstraint); | ||||||
| applyCustomConstraints(schema, value); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
|
|
@@ -383,6 +394,7 @@ public void writeBigInteger(Schema schema, BigInteger value) { | |||||
| schema.maxRangeConstraint.toBigInteger()) > 0) { | ||||||
| emitRangeError(schema, value); | ||||||
| } | ||||||
| applyCustomConstraints(schema, value); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
|
|
@@ -393,6 +405,7 @@ public void writeBigDecimal(Schema schema, BigDecimal value) { | |||||
| } else if (schema.maxRangeConstraint != null && value.compareTo(schema.maxRangeConstraint) > 0) { | ||||||
| emitRangeError(schema, value); | ||||||
| } | ||||||
| applyCustomConstraints(schema, value); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
|
|
@@ -429,6 +442,7 @@ public void writeString(Schema schema, String value) { | |||||
| } | ||||||
| default -> checkType(schema, ShapeType.STRING); // it's invalid, and calling this adds an error. | ||||||
| } | ||||||
| applyCustomConstraints(schema, value); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
|
|
@@ -438,16 +452,19 @@ public void writeBlob(Schema schema, ByteBuffer value) { | |||||
| if (length < schema.minLengthConstraint || length > schema.maxLengthConstraint) { | ||||||
| addError(new ValidationError.LengthValidationFailure(createPath(), length, schema)); | ||||||
| } | ||||||
| applyCustomConstraints(schema, value); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
| public void writeTimestamp(Schema schema, Instant value) { | ||||||
| checkType(schema, ShapeType.TIMESTAMP); | ||||||
| applyCustomConstraints(schema, value); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
| public void writeDocument(Schema schema, Document document) { | ||||||
| checkType(schema, ShapeType.DOCUMENT); | ||||||
| applyCustomConstraints(schema, document); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
|
|
@@ -488,5 +505,58 @@ private void checkType(Schema schema, ShapeType type) { | |||||
| throw new ValidationShortCircuitException(); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| private void applyCustomConstraints(Schema schema, Object value) { | ||||||
| if (!CustomConstraintProvider.HAS_CUSTOM_CONSTRAINTS) { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this feels a bit odd to me - we have a static field but we operate on a instance basis. Should either this to be related to an instance of |
||||||
| return; | ||||||
| } | ||||||
| var customConstraints = schema.getExtension(CustomConstraintProvider.CUSTOM_CONSTRAINT_EXTENSION_KEY); | ||||||
| if (customConstraints == null) { | ||||||
| return; | ||||||
| } | ||||||
| for (var rule: customConstraints) { | ||||||
| var validationErrors = rule.validate(schema, value, createPath()); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. createPath should be created up front and reused. Or better still, we could give the validator a Supplier instead of eagerly computing the path. |
||||||
| for (var error: validationErrors) { | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| addError(error); | ||||||
|
Comment on lines
+519
to
+520
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It might worth adding a new |
||||||
| } | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Registers a new schema extension for a list of {@link CustomConstraint} | ||||||
| */ | ||||||
| public static class CustomConstraintProvider implements SchemaExtensionProvider<List<CustomConstraint>> { | ||||||
| private static final SchemaExtensionKey<List<CustomConstraint>> CUSTOM_CONSTRAINT_EXTENSION_KEY = | ||||||
| new SchemaExtensionKey<>(); | ||||||
| private static final List<CustomConstraint> CUSTOM_CONSTRAINT_LIST = new ArrayList<>(); | ||||||
| private static final boolean HAS_CUSTOM_CONSTRAINTS; | ||||||
|
|
||||||
| public CustomConstraintProvider() {} | ||||||
|
|
||||||
| static { | ||||||
| // loading all custom constraints at once at startup | ||||||
| var loader = ServiceLoader.load(CustomConstraint.class, CustomConstraint.class.getClassLoader()); | ||||||
| for (var customRule: loader) { | ||||||
| CUSTOM_CONSTRAINT_LIST.add(customRule); | ||||||
| } | ||||||
| HAS_CUSTOM_CONSTRAINTS = !CUSTOM_CONSTRAINT_LIST.isEmpty(); | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
| public SchemaExtensionKey<List<CustomConstraint>> key() { | ||||||
| return CUSTOM_CONSTRAINT_EXTENSION_KEY; | ||||||
| } | ||||||
|
|
||||||
| @Override | ||||||
| public List<CustomConstraint> provide(Schema schema) { | ||||||
| var rulesForThisSchema = new ArrayList<CustomConstraint>(); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems heavy. It adds a list to every schema we validate. I think instead of this, we could have CustomConstraint return an
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
@mtdowling does it mean that there would be only one custom constraint possible per shape type? If so, which approach you'd choose to select which one should apply when there are multiple options? I'm curious if in this case the overall approach should be to pass "constraint per shape type" map to the validator constructor to avoid ambiguity when using SPI...
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should do away with the idea of per/shape custom validators, and instead have per/type custom validators (which there could be many per/type). Maybe with support for a wildcard which matches any type. If you need shape-specific validators (which would likely be rare), then that filtering can be done in |
||||||
| for (var rule: CUSTOM_CONSTRAINT_LIST) { | ||||||
| if (rule.appliesTo(schema)) { | ||||||
| rulesForThisSchema.add(rule); | ||||||
| } | ||||||
| } | ||||||
| return rulesForThisSchema; | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| software.amazon.smithy.java.core.schema.Validator$CustomConstraintProvider |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| /* | ||
| * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| package software.amazon.smithy.java.core.schema; | ||
|
|
||
| import java.util.List; | ||
| import software.amazon.smithy.model.shapes.ShapeType; | ||
|
|
||
| public final class TestCustomConstraints { | ||
|
|
||
| private TestCustomConstraints() {} | ||
|
|
||
| public static class AlwaysFailsConstraint implements CustomConstraint { | ||
| @Override | ||
| public boolean appliesTo(Schema schema) { | ||
| return schema.id().getNamespace().contains("CustomTest"); | ||
| } | ||
|
|
||
| @Override | ||
| public List<ValidationError> validate(Schema schema, Object value, String path) { | ||
| return List.of(new ValidationError.CustomValidationFailure( | ||
| path, | ||
| schema, | ||
| "Custom constraint failed")); | ||
| } | ||
| } | ||
|
|
||
| public static class StringOnlyConstraint implements CustomConstraint { | ||
| @Override | ||
| public boolean appliesTo(Schema schema) { | ||
| return schema.type() == ShapeType.STRING | ||
| && schema.id().getNamespace().contains("CustomTest"); | ||
| } | ||
|
|
||
| @Override | ||
| public List<ValidationError> validate(Schema schema, Object value, String path) { | ||
| return List.of(new ValidationError.CustomValidationFailure( | ||
| path, | ||
| schema, | ||
| "String-only constraint violated")); | ||
| } | ||
| } | ||
|
|
||
| public static class ListOnlyConstraint implements CustomConstraint { | ||
| @Override | ||
| public boolean appliesTo(Schema schema) { | ||
| return schema.type() == ShapeType.LIST | ||
| && schema.id().getNamespace().contains("CustomTest"); | ||
| } | ||
|
|
||
| @Override | ||
| public List<ValidationError> validate(Schema schema, Object value, String path) { | ||
| return List.of(new ValidationError.CustomValidationFailure( | ||
| path, | ||
| schema, | ||
| "List-only constraint violated")); | ||
| } | ||
| } | ||
|
|
||
| public static class StructOnlyConstraint implements CustomConstraint { | ||
|
|
||
| @Override | ||
| public boolean appliesTo(Schema schema) { | ||
| return schema.type() == ShapeType.STRUCTURE | ||
| && schema.id().getNamespace().contains("CustomTest"); | ||
| } | ||
|
|
||
| @Override | ||
| public List<ValidationError> validate(Schema schema, Object value, String path) { | ||
| return List.of(new ValidationError.CustomValidationFailure( | ||
| path, | ||
| schema, | ||
| "Struct-only constraint violated")); | ||
| } | ||
| } | ||
|
|
||
| public static class UnionOnlyConstraint implements CustomConstraint { | ||
|
|
||
| @Override | ||
| public boolean appliesTo(Schema schema) { | ||
| return schema.type() == ShapeType.UNION | ||
| && schema.id().getNamespace().contains("CustomTest"); | ||
| } | ||
|
|
||
| @Override | ||
| public List<ValidationError> validate(Schema schema, Object value, String path) { | ||
| return List.of(new ValidationError.CustomValidationFailure( | ||
| path, | ||
| schema, | ||
| "Union-only constraint violated")); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure this needs to be defined here,
ValidationErroris an open public interface that custom integrations can implement and define their own errors in their packages? Feels like redundant to me