diff --git a/core/src/main/java/software/amazon/smithy/java/core/schema/CustomConstraint.java b/core/src/main/java/software/amazon/smithy/java/core/schema/CustomConstraint.java new file mode 100644 index 000000000..06d9159a5 --- /dev/null +++ b/core/src/main/java/software/amazon/smithy/java/core/schema/CustomConstraint.java @@ -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. + * + *

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 validate(Schema schema, Object value, String path); +} diff --git a/core/src/main/java/software/amazon/smithy/java/core/schema/ValidationError.java b/core/src/main/java/software/amazon/smithy/java/core/schema/ValidationError.java index 37528a5d1..a04d03bb6 100644 --- a/core/src/main/java/software/amazon/smithy/java/core/schema/ValidationError.java +++ b/core/src/main/java/software/amazon/smithy/java/core/schema/ValidationError.java @@ -137,4 +137,6 @@ private static String createMessage(int position) { return "Conflicting list item found at position " + position; } } + + record CustomValidationFailure(String path, Schema schema, String message) implements ValidationError {} } diff --git a/core/src/main/java/software/amazon/smithy/java/core/schema/Validator.java b/core/src/main/java/software/amazon/smithy/java/core/schema/Validator.java index e04d9898e..ec6353b8e 100644 --- a/core/src/main/java/software/amazon/smithy/java/core/schema/Validator.java +++ b/core/src/main/java/software/amazon/smithy/java/core/schema/Validator.java @@ -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 void writeList(Schema schema, T state, int size, BiConsumer void writeMap(Schema schema, T state, int size, BiConsumer 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) { + 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()); + for (var error: validationErrors) { + addError(error); + } + } + } + } + + /** + * Registers a new schema extension for a list of {@link CustomConstraint} + */ + public static class CustomConstraintProvider implements SchemaExtensionProvider> { + private static final SchemaExtensionKey> CUSTOM_CONSTRAINT_EXTENSION_KEY = + new SchemaExtensionKey<>(); + private static final List 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> key() { + return CUSTOM_CONSTRAINT_EXTENSION_KEY; + } + + @Override + public List provide(Schema schema) { + var rulesForThisSchema = new ArrayList(); + for (var rule: CUSTOM_CONSTRAINT_LIST) { + if (rule.appliesTo(schema)) { + rulesForThisSchema.add(rule); + } + } + return rulesForThisSchema; + } } } diff --git a/core/src/main/resources/META-INF/services/software.amazon.smithy.java.core.schema.SchemaExtensionProvider b/core/src/main/resources/META-INF/services/software.amazon.smithy.java.core.schema.SchemaExtensionProvider new file mode 100644 index 000000000..3cfbd0e00 --- /dev/null +++ b/core/src/main/resources/META-INF/services/software.amazon.smithy.java.core.schema.SchemaExtensionProvider @@ -0,0 +1 @@ +software.amazon.smithy.java.core.schema.Validator$CustomConstraintProvider \ No newline at end of file diff --git a/core/src/test/java/software/amazon/smithy/java/core/schema/TestCustomConstraints.java b/core/src/test/java/software/amazon/smithy/java/core/schema/TestCustomConstraints.java new file mode 100644 index 000000000..a4eea54e1 --- /dev/null +++ b/core/src/test/java/software/amazon/smithy/java/core/schema/TestCustomConstraints.java @@ -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 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 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 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 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 validate(Schema schema, Object value, String path) { + return List.of(new ValidationError.CustomValidationFailure( + path, + schema, + "Union-only constraint violated")); + } + } +} diff --git a/core/src/test/java/software/amazon/smithy/java/core/schema/ValidatorTest.java b/core/src/test/java/software/amazon/smithy/java/core/schema/ValidatorTest.java index 6bddc4a2f..ce0161d4b 100644 --- a/core/src/test/java/software/amazon/smithy/java/core/schema/ValidatorTest.java +++ b/core/src/test/java/software/amazon/smithy/java/core/schema/ValidatorTest.java @@ -31,6 +31,7 @@ import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Consumer; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -1281,6 +1282,105 @@ static List validatesRequiredMembersOfBigStructsProvider() { Arguments.of(65, 65, 0, 65)); } + @Test + public void customConstraintsWork() { + var validator = Validator.builder().build(); + var schema = Schema.createInteger(ShapeId.from("smithy.CustomTest#TestInt")); + + var errors = validator.validate(encoder -> encoder.writeInteger(schema, 42)); + + assertThat(errors, hasSize(1)); + var error = errors.get(0); + assertThat(error.path(), equalTo("/")); + assertThat(error.message(), equalTo("Custom constraint failed")); + } + + @Test + public void customConstraintsAreSelective() { + var validator = Validator.builder().build(); + var stringSchema = Schema.createString(ShapeId.from("smithy.CustomTest#SelectiveString")); + + var errors = validator.validate(encoder -> encoder.writeString(stringSchema, "test")); + + assertThat(errors, hasSize(2)); + assertTrue(errors.stream() + .anyMatch(e -> e.message().equals("String-only constraint violated"))); + assertTrue(errors.stream() + .anyMatch(e -> e.message().equals("Custom constraint failed"))); + } + + @Nested + class CustomConstraintsOnNonPrimitiveShapes { + + @Test + void appliesOnStructs() { + var validator = Validator.builder().build(); + var structSchema = Schema.structureBuilder(ShapeId.from("smithy.CustomTest#TestStruct")) + .putMember("value", PreludeSchemas.INTEGER) + .build(); + + var errors = validator.validate(encoder -> { + encoder.writeStruct(structSchema, TestHelper.create(structSchema, (schema, serializer) -> { + serializer.writeInteger(schema.member("value"), 67); + })); + }); + + assertThat(errors, hasSize(3)); + assertTrue(errors.stream().anyMatch(e -> e.path().equals("/") + && e.message().equals("Custom constraint failed"))); + assertTrue(errors.stream().anyMatch(e -> e.path().equals("/") + && e.message().equals("Struct-only constraint violated"))); + assertTrue(errors.stream().anyMatch(e -> e.path().equals("/value") + && e.message().equals("Custom constraint failed"))); + } + + @Test + void appliesOnUnions() { + var validator = Validator.builder().build(); + var unionSchema = Schema.unionBuilder(ShapeId.from("smithy.CustomTest#TestUnion")) + .putMember("stringValue", PreludeSchemas.STRING) + .putMember("intValue", PreludeSchemas.INTEGER) + .build(); + + var errors = validator.validate(encoder -> { + encoder.writeStruct(unionSchema, TestHelper.create(unionSchema, (schema, serializer) -> { + serializer.writeString(schema.member("stringValue"), "value"); + })); + }); + + assertThat(errors, hasSize(4)); + assertTrue(errors.stream().anyMatch(e -> e.path().equals("/") + && e.message().equals("Custom constraint failed"))); + assertTrue(errors.stream().anyMatch(e -> e.path().equals("/") + && e.message().equals("Union-only constraint violated"))); + assertTrue(errors.stream().anyMatch(e -> e.path().equals("/stringValue") + && e.message().equals("Custom constraint failed"))); + assertTrue(errors.stream().anyMatch(e -> e.path().equals("/stringValue") + && e.message().equals("String-only constraint violated"))); + } + + @Test + void appliesOnLists() { + var validator = Validator.builder().build(); + var listSchema = Schema.listBuilder(ShapeId.from("smithy.CustomTest#TestList")) + .putMember("member", PreludeSchemas.INTEGER) + .build(); + + var errors = validator.validate(encoder -> { + encoder.writeList(listSchema, null, 2, (state, serializer) -> { + serializer.writeInteger(PreludeSchemas.INTEGER, 67); + serializer.writeInteger(PreludeSchemas.INTEGER, 67); + }); + }); + + assertThat(errors, hasSize(2)); + assertTrue(errors.stream().anyMatch(e -> e.path().equals("/") + && e.message().equals("Custom constraint failed"))); + assertTrue(errors.stream().anyMatch(e -> e.path().equals("/") + && e.message().equals("List-only constraint violated"))); + } + } + static Schema createBigRequiredSchema(int totalMembers, int requiredCount, int defaultedCount) { var builder = Schema.structureBuilder(ShapeId.from("smithy.example#Foo")); for (var i = 0; i < totalMembers; i++) { diff --git a/core/src/test/resources/META-INF/services/software.amazon.smithy.java.core.schema.CustomConstraint b/core/src/test/resources/META-INF/services/software.amazon.smithy.java.core.schema.CustomConstraint new file mode 100644 index 000000000..7d1a4e1f7 --- /dev/null +++ b/core/src/test/resources/META-INF/services/software.amazon.smithy.java.core.schema.CustomConstraint @@ -0,0 +1,5 @@ +software.amazon.smithy.java.core.schema.TestCustomConstraints$AlwaysFailsConstraint +software.amazon.smithy.java.core.schema.TestCustomConstraints$StringOnlyConstraint +software.amazon.smithy.java.core.schema.TestCustomConstraints$ListOnlyConstraint +software.amazon.smithy.java.core.schema.TestCustomConstraints$StructOnlyConstraint +software.amazon.smithy.java.core.schema.TestCustomConstraints$UnionOnlyConstraint