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,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
Expand Up @@ -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 {}
Copy link
Copy Markdown
Contributor

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, ValidationError is an open public interface that custom integrations can implement and define their own errors in their packages? Feels like redundant to me

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -260,6 +262,7 @@ public <T> void writeList(Schema schema, T state, int size, BiConsumer<T, ShapeS

checkListLength(schema, count);
}
applyCustomConstraints(schema, state);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -383,6 +394,7 @@ public void writeBigInteger(Schema schema, BigInteger value) {
schema.maxRangeConstraint.toBigInteger()) > 0) {
emitRangeError(schema, value);
}
applyCustomConstraints(schema, value);
}

@Override
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 CustomConstraintProvider or other methods (e.g. provide, key) to be static in the class?

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());
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
for (var error: validationErrors) {
for (var error : validationErrors) {

addError(error);
Comment on lines +519 to +520
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It might worth adding a new addErrors method as adding multiple objects should be faster than adding then one by one. Also, having it this way might lead to the situation where, say, you have maxAllowedErrors=1 but custom validator returned 2 errors (and these are the only errors during the validation) - we will add only first but the second one has been generated already so maybe we should still add it? It feels like maxAllowedErrors is more like a circuit breaker (to stop further validation) but if the validation is done already, why not just add these errors?

}
}
}
}

/**
* 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>();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 EnumSet<ShapeType> appliesTo(). Then we'd have a List<CustomConstraint>[] on the class, sized to the number of shape types. Then check if there's an entry per/shape being validated. Then there's no SchemaExtension and no need to punch an array/schema onto all schemas.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Then we'd have a List[] on the class, sized to the number of shape types

@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...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 validate by inspecting the Schema.

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"));
}
}
}
Loading