564 lines
17 KiB
Rust
564 lines
17 KiB
Rust
use wip_protocol::{
|
|
EnumDeclaration, EnumVariantDeclaration, FieldDeclaration, InterfaceDescriptor,
|
|
InterfaceObservation, JsonMember, JsonValue, MAX_SAFE_INTEGER, NameNamespace, NamedValue,
|
|
Object, ObjectId, OperationCall, OperationDeclaration, OperationTarget, ParameterDeclaration,
|
|
PathSegment, RecordDeclaration, ReturnDeclaration, TypeDeclaration, TypeExpr, UnionDeclaration,
|
|
UnionVariantDeclaration, ValidationErrorKind, Value, ValueKind,
|
|
};
|
|
|
|
fn field(name: &str, ty: TypeExpr, optional: bool) -> FieldDeclaration {
|
|
FieldDeclaration {
|
|
name: name.into(),
|
|
documentation: None,
|
|
ty,
|
|
optional,
|
|
}
|
|
}
|
|
|
|
fn descriptor(name: &str) -> InterfaceDescriptor {
|
|
InterfaceDescriptor {
|
|
name: name.into(),
|
|
documentation: None,
|
|
declarations: vec![
|
|
TypeDeclaration::Enum(EnumDeclaration {
|
|
name: "Color".into(),
|
|
documentation: None,
|
|
variants: vec![
|
|
EnumVariantDeclaration {
|
|
name: "red".into(),
|
|
documentation: None,
|
|
},
|
|
EnumVariantDeclaration {
|
|
name: "blue".into(),
|
|
documentation: None,
|
|
},
|
|
],
|
|
}),
|
|
TypeDeclaration::Record(RecordDeclaration {
|
|
name: "Payload".into(),
|
|
documentation: None,
|
|
fields: vec![
|
|
field("title", TypeExpr::String, false),
|
|
field("color", TypeExpr::Named("Color".into()), true),
|
|
field(
|
|
"metadata",
|
|
TypeExpr::Entry {
|
|
key: Box::new(TypeExpr::String),
|
|
value: Box::new(TypeExpr::Json),
|
|
},
|
|
true,
|
|
),
|
|
],
|
|
}),
|
|
TypeDeclaration::Union(UnionDeclaration {
|
|
name: "Selection".into(),
|
|
documentation: None,
|
|
variants: vec![
|
|
UnionVariantDeclaration {
|
|
name: "by_name".into(),
|
|
documentation: None,
|
|
ty: TypeExpr::String,
|
|
},
|
|
UnionVariantDeclaration {
|
|
name: "by_id".into(),
|
|
documentation: None,
|
|
ty: TypeExpr::Integer,
|
|
},
|
|
],
|
|
}),
|
|
],
|
|
operations: vec![OperationDeclaration {
|
|
name: "publish".into(),
|
|
documentation: None,
|
|
parameters: vec![
|
|
ParameterDeclaration {
|
|
name: "payload".into(),
|
|
documentation: None,
|
|
ty: TypeExpr::Named("Payload".into()),
|
|
optional: false,
|
|
},
|
|
ParameterDeclaration {
|
|
name: "selection".into(),
|
|
documentation: None,
|
|
ty: TypeExpr::Named("Selection".into()),
|
|
optional: true,
|
|
},
|
|
],
|
|
returns: Some(ReturnDeclaration {
|
|
documentation: None,
|
|
ty: TypeExpr::Boolean,
|
|
}),
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn named(name: &str, value: Value) -> NamedValue {
|
|
NamedValue {
|
|
name: name.into(),
|
|
value,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn valid_descriptor_values_call_and_return_are_accepted() {
|
|
let descriptor = descriptor("catalog");
|
|
descriptor.validate().unwrap();
|
|
|
|
let payload = Value::Record(vec![
|
|
named("title", Value::String("hello".into())),
|
|
named("color", Value::Enum("red".into())),
|
|
named(
|
|
"metadata",
|
|
Value::Entry {
|
|
key: Box::new(Value::String("source".into())),
|
|
value: Box::new(Value::Json(JsonValue::Object(vec![JsonMember {
|
|
name: "trusted".into(),
|
|
value: JsonValue::Boolean(true),
|
|
}]))),
|
|
},
|
|
),
|
|
]);
|
|
descriptor
|
|
.validate_value(&TypeExpr::Named("Payload".into()), &payload)
|
|
.unwrap();
|
|
|
|
let call = OperationCall {
|
|
target: OperationTarget {
|
|
object: ObjectId("object-1".into()),
|
|
interface: "catalog".into(),
|
|
operation: "publish".into(),
|
|
},
|
|
arguments: vec![named("payload", payload)],
|
|
};
|
|
descriptor.validate_call(&call).unwrap();
|
|
descriptor
|
|
.validate_return("publish", Some(&Value::Boolean(true)))
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_declaration_names_are_structured_errors() {
|
|
let mut descriptor = descriptor("catalog");
|
|
descriptor
|
|
.declarations
|
|
.push(TypeDeclaration::Record(RecordDeclaration {
|
|
name: "Color".into(),
|
|
documentation: None,
|
|
fields: vec![],
|
|
}));
|
|
|
|
let error = descriptor.validate().unwrap_err();
|
|
assert_eq!(
|
|
error.kind,
|
|
ValidationErrorKind::DuplicateName {
|
|
namespace: NameNamespace::TypeDeclaration,
|
|
name: "Color".into(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
error.path,
|
|
vec![
|
|
PathSegment::Interface("catalog".into()),
|
|
PathSegment::Declaration("Color".into())
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn member_and_operation_names_are_unique_in_their_own_scopes() {
|
|
let mut duplicate_field = descriptor("one");
|
|
let TypeDeclaration::Record(record) = &mut duplicate_field.declarations[1] else {
|
|
unreachable!()
|
|
};
|
|
record.fields.push(field("title", TypeExpr::String, false));
|
|
assert!(matches!(
|
|
duplicate_field.validate().unwrap_err().kind,
|
|
ValidationErrorKind::DuplicateName {
|
|
namespace: NameNamespace::Field,
|
|
..
|
|
}
|
|
));
|
|
|
|
let mut duplicate_operation = descriptor("one");
|
|
duplicate_operation
|
|
.operations
|
|
.push(duplicate_operation.operations[0].clone());
|
|
assert!(matches!(
|
|
duplicate_operation.validate().unwrap_err().kind,
|
|
ValidationErrorKind::DuplicateName {
|
|
namespace: NameNamespace::Operation,
|
|
..
|
|
}
|
|
));
|
|
|
|
let mut duplicate_parameter = descriptor("one");
|
|
let repeated_parameter = duplicate_parameter.operations[0].parameters[0].clone();
|
|
duplicate_parameter.operations[0]
|
|
.parameters
|
|
.push(repeated_parameter);
|
|
assert!(matches!(
|
|
duplicate_parameter.validate().unwrap_err().kind,
|
|
ValidationErrorKind::DuplicateName {
|
|
namespace: NameNamespace::Parameter,
|
|
..
|
|
}
|
|
));
|
|
|
|
// Operation names are local to an interface, so another descriptor may
|
|
// independently declare the same operation name.
|
|
descriptor("one").validate().unwrap();
|
|
descriptor("two").validate().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn named_references_must_exist_and_must_not_cycle() {
|
|
let mut unknown = descriptor("catalog");
|
|
let TypeDeclaration::Record(record) = &mut unknown.declarations[1] else {
|
|
unreachable!()
|
|
};
|
|
record.fields[0].ty = TypeExpr::Named("Elsewhere".into());
|
|
let error = unknown.validate().unwrap_err();
|
|
assert_eq!(
|
|
error.kind,
|
|
ValidationErrorKind::UnknownType {
|
|
name: "Elsewhere".into()
|
|
}
|
|
);
|
|
assert_eq!(
|
|
error.path,
|
|
vec![
|
|
PathSegment::Interface("catalog".into()),
|
|
PathSegment::Declaration("Payload".into()),
|
|
PathSegment::Field("title".into()),
|
|
]
|
|
);
|
|
|
|
let valid = descriptor("catalog");
|
|
let error = valid
|
|
.validate_value(&TypeExpr::Named("Elsewhere".into()), &Value::Record(vec![]))
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
error.kind,
|
|
ValidationErrorKind::UnknownType {
|
|
name: "Elsewhere".into()
|
|
}
|
|
);
|
|
assert_eq!(error.path, vec![PathSegment::Interface("catalog".into())]);
|
|
|
|
let error = valid
|
|
.validate_value(
|
|
&TypeExpr::Entry {
|
|
key: Box::new(TypeExpr::String),
|
|
value: Box::new(TypeExpr::List(Box::new(TypeExpr::Named("Nested".into())))),
|
|
},
|
|
&Value::Entry {
|
|
key: Box::new(Value::String("key".into())),
|
|
value: Box::new(Value::List(vec![])),
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
error.kind,
|
|
ValidationErrorKind::UnknownType {
|
|
name: "Nested".into()
|
|
}
|
|
);
|
|
assert_eq!(
|
|
error.path,
|
|
vec![
|
|
PathSegment::Interface("catalog".into()),
|
|
PathSegment::EntryValue,
|
|
]
|
|
);
|
|
|
|
let cyclic = InterfaceDescriptor {
|
|
name: "cyclic".into(),
|
|
documentation: None,
|
|
declarations: vec![
|
|
TypeDeclaration::Record(RecordDeclaration {
|
|
name: "A".into(),
|
|
documentation: None,
|
|
fields: vec![field("b", TypeExpr::Named("B".into()), false)],
|
|
}),
|
|
TypeDeclaration::Union(UnionDeclaration {
|
|
name: "B".into(),
|
|
documentation: None,
|
|
variants: vec![UnionVariantDeclaration {
|
|
name: "a".into(),
|
|
documentation: None,
|
|
ty: TypeExpr::List(Box::new(TypeExpr::Named("A".into()))),
|
|
}],
|
|
}),
|
|
],
|
|
operations: vec![],
|
|
};
|
|
assert_eq!(
|
|
cyclic.validate().unwrap_err().kind,
|
|
ValidationErrorKind::RecursiveType {
|
|
cycle: vec!["A".into(), "B".into(), "A".into()]
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn records_reject_unknown_missing_duplicate_and_mistyped_fields() {
|
|
let descriptor = descriptor("catalog");
|
|
let ty = TypeExpr::Named("Payload".into());
|
|
|
|
let error = descriptor
|
|
.validate_value(
|
|
&ty,
|
|
&Value::Record(vec![
|
|
named("title", Value::String("ok".into())),
|
|
named("extra", Value::Boolean(true)),
|
|
]),
|
|
)
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
error.kind,
|
|
ValidationErrorKind::UnknownField {
|
|
name: "extra".into()
|
|
}
|
|
);
|
|
|
|
let error = descriptor
|
|
.validate_value(&ty, &Value::Record(vec![]))
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
error.kind,
|
|
ValidationErrorKind::MissingField {
|
|
name: "title".into()
|
|
}
|
|
);
|
|
|
|
let error = descriptor
|
|
.validate_value(
|
|
&ty,
|
|
&Value::Record(vec![
|
|
named("title", Value::String("one".into())),
|
|
named("title", Value::String("two".into())),
|
|
]),
|
|
)
|
|
.unwrap_err();
|
|
assert!(matches!(
|
|
error.kind,
|
|
ValidationErrorKind::DuplicateName {
|
|
namespace: NameNamespace::RecordValueField,
|
|
..
|
|
}
|
|
));
|
|
|
|
let error = descriptor
|
|
.validate_value(&ty, &Value::Record(vec![named("title", Value::Integer(1))]))
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
error.kind,
|
|
ValidationErrorKind::TypeMismatch {
|
|
expected: ValueKind::String,
|
|
actual: ValueKind::Integer,
|
|
}
|
|
);
|
|
|
|
// Optional means omission: there is no general-purpose Value::Null.
|
|
descriptor
|
|
.validate_value(
|
|
&ty,
|
|
&Value::Record(vec![named("title", Value::String("ok".into()))]),
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn integers_numbers_enums_unions_entries_and_json_are_validated() {
|
|
let descriptor = descriptor("catalog");
|
|
|
|
descriptor
|
|
.validate_value(&TypeExpr::Integer, &Value::Integer(MAX_SAFE_INTEGER))
|
|
.unwrap();
|
|
assert_eq!(
|
|
descriptor
|
|
.validate_value(&TypeExpr::Integer, &Value::Integer(MAX_SAFE_INTEGER + 1))
|
|
.unwrap_err()
|
|
.kind,
|
|
ValidationErrorKind::IntegerOutOfRange {
|
|
value: MAX_SAFE_INTEGER + 1
|
|
}
|
|
);
|
|
assert_eq!(
|
|
descriptor
|
|
.validate_value(&TypeExpr::Number, &Value::Number(f64::INFINITY))
|
|
.unwrap_err()
|
|
.kind,
|
|
ValidationErrorKind::NonFiniteNumber
|
|
);
|
|
|
|
assert!(matches!(
|
|
descriptor
|
|
.validate_value(
|
|
&TypeExpr::Named("Color".into()),
|
|
&Value::Enum("green".into())
|
|
)
|
|
.unwrap_err()
|
|
.kind,
|
|
ValidationErrorKind::UnknownVariant { .. }
|
|
));
|
|
descriptor
|
|
.validate_value(
|
|
&TypeExpr::Named("Selection".into()),
|
|
&Value::Union {
|
|
variant: "by_id".into(),
|
|
value: Box::new(Value::Integer(7)),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
descriptor
|
|
.validate_value(
|
|
&TypeExpr::Named("Selection".into()),
|
|
&Value::Union {
|
|
variant: "by_id".into(),
|
|
value: Box::new(Value::String("wrong".into())),
|
|
},
|
|
)
|
|
.unwrap_err()
|
|
.kind,
|
|
ValidationErrorKind::TypeMismatch { .. }
|
|
));
|
|
|
|
let entry_ty = TypeExpr::Entry {
|
|
key: Box::new(TypeExpr::String),
|
|
value: Box::new(TypeExpr::Integer),
|
|
};
|
|
let error = descriptor
|
|
.validate_value(
|
|
&entry_ty,
|
|
&Value::Entry {
|
|
key: Box::new(Value::String("key".into())),
|
|
value: Box::new(Value::Boolean(false)),
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
assert_eq!(error.path.last(), Some(&PathSegment::EntryValue));
|
|
|
|
let json = Value::Json(JsonValue::Object(vec![
|
|
JsonMember {
|
|
name: "same".into(),
|
|
value: JsonValue::Null,
|
|
},
|
|
JsonMember {
|
|
name: "same".into(),
|
|
value: JsonValue::Number(f64::NAN),
|
|
},
|
|
]));
|
|
assert!(matches!(
|
|
descriptor
|
|
.validate_value(&TypeExpr::Json, &json)
|
|
.unwrap_err()
|
|
.kind,
|
|
ValidationErrorKind::DuplicateName {
|
|
namespace: NameNamespace::JsonMember,
|
|
..
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn object_interfaces_are_an_ordered_set_and_observations_correspond() {
|
|
let object = Object {
|
|
id: ObjectId("object-1".into()),
|
|
interfaces: vec!["catalog".into(), "search".into()],
|
|
};
|
|
object.validate().unwrap();
|
|
assert_eq!(object.interfaces, vec!["catalog", "search"]);
|
|
|
|
let duplicate = Object {
|
|
id: ObjectId("object-2".into()),
|
|
interfaces: vec!["catalog".into(), "catalog".into()],
|
|
};
|
|
assert_eq!(
|
|
duplicate.validate().unwrap_err().kind,
|
|
ValidationErrorKind::DuplicateInterface {
|
|
name: "catalog".into()
|
|
}
|
|
);
|
|
|
|
let observation = InterfaceObservation {
|
|
object: object.clone(),
|
|
descriptors: vec![descriptor("catalog"), descriptor("search")],
|
|
};
|
|
observation.validate().unwrap();
|
|
|
|
let missing = InterfaceObservation {
|
|
object,
|
|
descriptors: vec![descriptor("catalog")],
|
|
};
|
|
assert_eq!(
|
|
missing.validate().unwrap_err().kind,
|
|
ValidationErrorKind::MissingInterfaceDescriptor {
|
|
name: "search".into()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn calls_reject_unknown_missing_duplicate_and_bad_arguments_and_returns() {
|
|
let catalog = descriptor("catalog");
|
|
let target = OperationTarget {
|
|
object: ObjectId("object-1".into()),
|
|
interface: "catalog".into(),
|
|
operation: "publish".into(),
|
|
};
|
|
|
|
let missing = OperationCall {
|
|
target: target.clone(),
|
|
arguments: vec![],
|
|
};
|
|
assert_eq!(
|
|
catalog.validate_call(&missing).unwrap_err().kind,
|
|
ValidationErrorKind::MissingParameter {
|
|
name: "payload".into()
|
|
}
|
|
);
|
|
|
|
let unknown = OperationCall {
|
|
target: target.clone(),
|
|
arguments: vec![named("other", Value::Boolean(true))],
|
|
};
|
|
assert_eq!(
|
|
catalog.validate_call(&unknown).unwrap_err().kind,
|
|
ValidationErrorKind::UnknownParameter {
|
|
name: "other".into()
|
|
}
|
|
);
|
|
|
|
let duplicate = OperationCall {
|
|
target,
|
|
arguments: vec![
|
|
named("payload", Value::Record(vec![])),
|
|
named("payload", Value::Record(vec![])),
|
|
],
|
|
};
|
|
assert!(matches!(
|
|
catalog.validate_call(&duplicate).unwrap_err().kind,
|
|
ValidationErrorKind::DuplicateName {
|
|
namespace: NameNamespace::Argument,
|
|
..
|
|
}
|
|
));
|
|
|
|
assert_eq!(
|
|
catalog.validate_return("publish", None).unwrap_err().kind,
|
|
ValidationErrorKind::MissingReturnValue
|
|
);
|
|
|
|
let mut no_return = descriptor("catalog");
|
|
no_return.operations[0].returns = None;
|
|
assert_eq!(
|
|
no_return
|
|
.validate_return("publish", Some(&Value::Boolean(true)))
|
|
.unwrap_err()
|
|
.kind,
|
|
ValidationErrorKind::UnexpectedReturnValue
|
|
);
|
|
}
|