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