feat(protocol): add logical model and validation
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
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 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user