feat(protocol): add logical model and validation

This commit is contained in:
2026-09-24 05:29:20 +09:00
parent ba4ccf1e67
commit 742442f174
8 changed files with 1818 additions and 0 deletions
+525
View File
@@ -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
);
}