diff --git a/Cargo.lock b/Cargo.lock index 6a484ef..4a5e4a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,13 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "wip-protocol" +version = "0.1.0" + [[package]] name = "wip-rs" version = "0.1.0" +dependencies = [ + "wip-protocol", +] diff --git a/Cargo.toml b/Cargo.toml index cd6568f..575d55c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,11 @@ +[workspace] +members = [".", "crates/wip-protocol"] +resolver = "3" + [package] name = "wip-rs" version = "0.1.0" edition = "2024" [dependencies] +wip-protocol = { path = "crates/wip-protocol" } diff --git a/crates/wip-protocol/Cargo.toml b/crates/wip-protocol/Cargo.toml new file mode 100644 index 0000000..d83f6cd --- /dev/null +++ b/crates/wip-protocol/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "wip-protocol" +version = "0.1.0" +edition = "2024" +description = "Transport-independent logical model and validation for WIP" +license = "MIT OR Apache-2.0" + +[dependencies] diff --git a/crates/wip-protocol/src/error.rs b/crates/wip-protocol/src/error.rs new file mode 100644 index 0000000..dba04b1 --- /dev/null +++ b/crates/wip-protocol/src/error.rs @@ -0,0 +1,240 @@ +use std::error::Error; +use std::fmt::{self, Display, Formatter}; + +/// Namespace in which a declaration name must be unique. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NameNamespace { + /// Named types in an interface descriptor. + TypeDeclaration, + /// Operations in an interface descriptor. + Operation, + /// Fields in a record declaration. + Field, + /// Parameters in an operation declaration. + Parameter, + /// Variants in an enum declaration. + EnumVariant, + /// Variants in a union declaration. + UnionVariant, + /// Fields supplied in a record value. + RecordValueField, + /// Arguments supplied in an operation call. + Argument, + /// Members in a logical JSON object. + JsonMember, + /// Descriptors in an interface observation. + InterfaceDescriptor, +} + +/// Structural kind of a logical protocol value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ValueKind { + /// String value. + String, + /// Boolean value. + Boolean, + /// Integer value. + Integer, + /// Floating-point number value. + Number, + /// Byte sequence. + Bytes, + /// List value. + List, + /// Key/value entry. + Entry, + /// Record value. + Record, + /// Enum value. + Enum, + /// Tagged-union value. + Union, + /// Logical JSON value. + Json, +} + +/// A typed segment identifying a declaration or value location. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PathSegment { + /// Interface descriptor. + Interface(String), + /// Named type declaration. + Declaration(String), + /// Operation declaration or call. + Operation(String), + /// Record field declaration or value. + Field(String), + /// Operation parameter declaration or argument. + Parameter(String), + /// Enum or union variant. + Variant(String), + /// List or JSON array index. + Index(usize), + /// Entry key. + EntryKey, + /// Entry value. + EntryValue, + /// Operation return value. + Return, + /// JSON object member. + JsonMember(String), +} + +/// Machine-readable reason why protocol validation failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ValidationErrorKind { + /// A name occurred more than once in one namespace. + DuplicateName { + /// Namespace containing the duplicate. + namespace: NameNamespace, + /// Duplicate name. + name: String, + }, + /// A named type was not declared in the descriptor. + UnknownType { + /// Missing descriptor-local type name. + name: String, + }, + /// Named declarations form a recursive cycle. + RecursiveType { + /// Cycle in traversal order, with the starting name repeated last. + cycle: Vec, + }, + /// A value has the wrong structural kind. + TypeMismatch { + /// Expected kind. + expected: ValueKind, + /// Actual kind. + actual: ValueKind, + }, + /// A record value contains an undeclared field. + UnknownField { + /// Unknown field name. + name: String, + }, + /// A record value omitted a required field. + MissingField { + /// Missing field name. + name: String, + }, + /// An enum or union value selected an undeclared variant. + UnknownVariant { + /// Unknown variant name. + name: String, + }, + /// An operation call contains an undeclared argument. + UnknownParameter { + /// Unknown parameter name. + name: String, + }, + /// An operation call omitted a required argument. + MissingParameter { + /// Missing parameter name. + name: String, + }, + /// An integer is outside the JavaScript-lossless range. + IntegerOutOfRange { + /// Rejected integer. + value: i64, + }, + /// A numeric value is NaN or infinite. + NonFiniteNumber, + /// An object's ordered interface set contains a duplicate. + DuplicateInterface { + /// Duplicate interface name. + name: String, + }, + /// An observation omits a descriptor named by its object. + MissingInterfaceDescriptor { + /// Missing interface name. + name: String, + }, + /// An observation contains a descriptor not named by its object. + UnexpectedInterfaceDescriptor { + /// Unexpected interface name. + name: String, + }, + /// A call targets an interface other than the descriptor being used. + InterfaceMismatch { + /// Descriptor interface name. + expected: String, + /// Call target interface name. + actual: String, + }, + /// An operation is not declared by the interface. + UnknownOperation { + /// Unknown operation name. + name: String, + }, + /// A no-return operation produced a value. + UnexpectedReturnValue, + /// An operation with a return declaration produced no value. + MissingReturnValue, +} + +/// A validation failure with a typed path to the failing location. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidationError { + /// Path from the validation root to the failure. + pub path: Vec, + /// Machine-readable failure reason. + pub kind: ValidationErrorKind, +} + +impl ValidationError { + pub(crate) fn new(kind: ValidationErrorKind) -> Self { + Self { + path: Vec::new(), + kind, + } + } + + pub(crate) fn prepend(mut self, segment: PathSegment) -> Self { + self.path.insert(0, segment); + self + } +} + +impl Display for ValidationError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + if self.path.is_empty() { + write!(formatter, "protocol validation failed: {:?}", self.kind) + } else { + write!( + formatter, + "protocol validation failed at {:?}: {:?}", + self.path, self.kind + ) + } + } +} + +impl Error for ValidationError {} + +/// Stable category of an error returned by a protocol host runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtocolErrorCategory { + /// The request is structurally invalid. + InvalidRequest, + /// The target object is not published. + ObjectNotFound, + /// The target interface is not implemented by the object. + InterfaceNotFound, + /// The target operation is not declared by the interface. + OperationNotFound, + /// Supplied operation arguments are invalid. + InvalidArguments, + /// The implementation rejected the call for a domain reason. + OperationFailed, + /// The host encountered an internal failure. + Internal, +} + +/// Transport-independent protocol failure returned by a host runtime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProtocolError { + /// Stable programmatic category. + pub category: ProtocolErrorCategory, + /// Human-readable diagnostic. Consumers must not branch on this text. + pub message: String, +} diff --git a/crates/wip-protocol/src/lib.rs b/crates/wip-protocol/src/lib.rs new file mode 100644 index 0000000..c6275f1 --- /dev/null +++ b/crates/wip-protocol/src/lib.rs @@ -0,0 +1,30 @@ +//! Transport-independent logical types for the Web Interface Protocol (WIP). +//! +//! This crate deliberately models interface declarations, published objects, +//! operation calls, and operation results without assigning them HTTP routes, +//! methods, headers, or JSON envelopes. A transport can encode these values, +//! but the encoding is outside this crate's contract. + +#![deny(missing_docs)] + +mod error; +mod model; +mod validation; + +pub use error::{ + NameNamespace, PathSegment, ProtocolError, ProtocolErrorCategory, ValidationError, + ValidationErrorKind, ValueKind, +}; +pub use model::{ + Documentation, EnumDeclaration, EnumVariantDeclaration, FieldDeclaration, InterfaceDescriptor, + InterfaceObservation, JsonMember, JsonValue, NamedValue, Object, ObjectId, OperationCall, + OperationDeclaration, OperationOutcome, OperationResult, OperationTarget, ParameterDeclaration, + RecordDeclaration, ReturnDeclaration, TypeDeclaration, TypeExpr, UnionDeclaration, + UnionVariantDeclaration, Value, +}; + +/// Largest integer that is exactly interoperable with a JavaScript `Number`. +pub const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; + +/// Smallest integer that is exactly interoperable with a JavaScript `Number`. +pub const MIN_SAFE_INTEGER: i64 = -MAX_SAFE_INTEGER; diff --git a/crates/wip-protocol/src/model.rs b/crates/wip-protocol/src/model.rs new file mode 100644 index 0000000..88a64a9 --- /dev/null +++ b/crates/wip-protocol/src/model.rs @@ -0,0 +1,383 @@ +use crate::{ProtocolError, ValidationError}; + +/// Human-readable documentation attached to a protocol declaration. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Documentation { + /// A short, self-contained description. + pub summary: String, + /// Optional extended documentation. + pub details: Option, +} + +/// Stable logical identity of a published object. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ObjectId(pub String); + +/// A published object and the ordered set of interfaces it implements. +/// +/// The vector order is significant. Call [`Object::validate`] before +/// publication to reject duplicate interface names. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Object { + /// Logical object identity. + pub id: ObjectId, + /// Interface names in publication order. + pub interfaces: Vec, +} + +/// A complete logical observation of an object and its interface descriptors. +#[derive(Debug, Clone, PartialEq)] +pub struct InterfaceObservation { + /// Observed object. + pub object: Object, + /// Descriptors corresponding to `object.interfaces`. + pub descriptors: Vec, +} + +/// Transport-independent description of one interface. +#[derive(Debug, Clone, PartialEq)] +pub struct InterfaceDescriptor { + /// Interface name. + pub name: String, + /// Optional interface documentation. + pub documentation: Option, + /// Named type declarations local to this descriptor. + pub declarations: Vec, + /// Operations exposed by this interface. + pub operations: Vec, +} + +/// A named type declaration local to an interface descriptor. +#[derive(Debug, Clone, PartialEq)] +pub enum TypeDeclaration { + /// A record declaration. + Record(RecordDeclaration), + /// An enumeration declaration. + Enum(EnumDeclaration), + /// A tagged union declaration. + Union(UnionDeclaration), +} + +impl TypeDeclaration { + /// Returns the descriptor-local declaration name. + #[must_use] + pub fn name(&self) -> &str { + match self { + Self::Record(declaration) => &declaration.name, + Self::Enum(declaration) => &declaration.name, + Self::Union(declaration) => &declaration.name, + } + } +} + +/// Declaration of a record with named fields. +#[derive(Debug, Clone, PartialEq)] +pub struct RecordDeclaration { + /// Descriptor-local type name. + pub name: String, + /// Optional record documentation. + pub documentation: Option, + /// Fields in declaration order. + pub fields: Vec, +} + +/// Declaration of a record field. +#[derive(Debug, Clone, PartialEq)] +pub struct FieldDeclaration { + /// Field name. + pub name: String, + /// Optional field documentation. + pub documentation: Option, + /// Field value type. + pub ty: TypeExpr, + /// Whether the field may be omitted. + /// + /// Optionality represents absence, not a `null` value. + pub optional: bool, +} + +/// Declaration of an enumeration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnumDeclaration { + /// Descriptor-local type name. + pub name: String, + /// Optional enum documentation. + pub documentation: Option, + /// Allowed variants in declaration order. + pub variants: Vec, +} + +/// Declaration of one enumeration variant. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnumVariantDeclaration { + /// Variant name. + pub name: String, + /// Optional variant documentation. + pub documentation: Option, +} + +/// Declaration of a tagged union. +#[derive(Debug, Clone, PartialEq)] +pub struct UnionDeclaration { + /// Descriptor-local type name. + pub name: String, + /// Optional union documentation. + pub documentation: Option, + /// Allowed tagged variants in declaration order. + pub variants: Vec, +} + +/// Declaration of one tagged-union variant. +#[derive(Debug, Clone, PartialEq)] +pub struct UnionVariantDeclaration { + /// Variant tag. + pub name: String, + /// Optional variant documentation. + pub documentation: Option, + /// Payload type. + pub ty: TypeExpr, +} + +/// A type expression used by fields, parameters, returns, and union payloads. +/// +/// `Named` references are resolved only within the containing descriptor. +/// Optional values are represented by the containing field or parameter and +/// therefore are intentionally not a type expression. +#[derive(Debug, Clone, PartialEq)] +pub enum TypeExpr { + /// A UTF-8 string. + String, + /// A boolean. + Boolean, + /// A JavaScript-lossless integer. + Integer, + /// A finite IEEE-754 double precision number. + Number, + /// An opaque byte sequence. + Bytes, + /// An ordered sequence of values. + List(Box), + /// A key/value entry. + Entry { + /// Entry key type. + key: Box, + /// Entry value type. + value: Box, + }, + /// A logical JSON value. + Json, + /// A descriptor-local named declaration. + Named(String), +} + +/// Declaration of one interface operation. +#[derive(Debug, Clone, PartialEq)] +pub struct OperationDeclaration { + /// Operation name, unique within its interface. + pub name: String, + /// Optional operation documentation. + pub documentation: Option, + /// Parameters in declaration order. + pub parameters: Vec, + /// Return declaration, or `None` for an operation with no return value. + pub returns: Option, +} + +/// Declaration of one operation parameter. +#[derive(Debug, Clone, PartialEq)] +pub struct ParameterDeclaration { + /// Parameter name. + pub name: String, + /// Optional parameter documentation. + pub documentation: Option, + /// Parameter value type. + pub ty: TypeExpr, + /// Whether the parameter may be omitted. + /// + /// Optionality represents absence, not a `null` value. + pub optional: bool, +} + +/// Declaration of an operation return value. +#[derive(Debug, Clone, PartialEq)] +pub struct ReturnDeclaration { + /// Optional return-value documentation. + pub documentation: Option, + /// Return value type. + pub ty: TypeExpr, +} + +/// A logical protocol value. +/// +/// There is no general `Null` variant: optional record fields and operation +/// parameters are omitted. JSON null remains available inside [`Value::Json`]. +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + /// A UTF-8 string. + String(String), + /// A boolean. + Boolean(bool), + /// An integer, validated against the JavaScript safe integer range. + Integer(i64), + /// A number, validated to be finite. + Number(f64), + /// An opaque byte sequence. + Bytes(Vec), + /// An ordered sequence. + List(Vec), + /// A key/value entry. + Entry { + /// Entry key. + key: Box, + /// Entry value. + value: Box, + }, + /// Record fields. Duplicate names are invalid. + Record(Vec), + /// An enum variant name. + Enum(String), + /// A tagged-union value. + Union { + /// Selected variant tag. + variant: String, + /// Variant payload. + value: Box, + }, + /// A logical JSON value. + Json(JsonValue), +} + +impl Value { + /// Returns the structural kind used by validation diagnostics. + #[must_use] + pub fn kind(&self) -> crate::ValueKind { + match self { + Self::String(_) => crate::ValueKind::String, + Self::Boolean(_) => crate::ValueKind::Boolean, + Self::Integer(_) => crate::ValueKind::Integer, + Self::Number(_) => crate::ValueKind::Number, + Self::Bytes(_) => crate::ValueKind::Bytes, + Self::List(_) => crate::ValueKind::List, + Self::Entry { .. } => crate::ValueKind::Entry, + Self::Record(_) => crate::ValueKind::Record, + Self::Enum(_) => crate::ValueKind::Enum, + Self::Union { .. } => crate::ValueKind::Union, + Self::Json(_) => crate::ValueKind::Json, + } + } +} + +/// A named value used by records and operation arguments. +#[derive(Debug, Clone, PartialEq)] +pub struct NamedValue { + /// Field or parameter name. + pub name: String, + /// Associated value. + pub value: Value, +} + +/// A transport-independent JSON value. +#[derive(Debug, Clone, PartialEq)] +pub enum JsonValue { + /// JSON null. + Null, + /// JSON boolean. + Boolean(bool), + /// JSON integer, validated against the JavaScript safe integer range. + Integer(i64), + /// JSON number, validated to be finite. + Number(f64), + /// JSON string. + String(String), + /// JSON array. + Array(Vec), + /// JSON object. Duplicate member names are invalid. + Object(Vec), +} + +/// A member of a logical JSON object. +#[derive(Debug, Clone, PartialEq)] +pub struct JsonMember { + /// Member name. + pub name: String, + /// Member value. + pub value: JsonValue, +} + +/// Logical destination of an operation call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OperationTarget { + /// Target object. + pub object: ObjectId, + /// Target interface name. + pub interface: String, + /// Target operation name. + pub operation: String, +} + +/// A transport-independent operation call. +#[derive(Debug, Clone, PartialEq)] +pub struct OperationCall { + /// Logical call target. + pub target: OperationTarget, + /// Named arguments. Optional arguments are represented by omission. + pub arguments: Vec, +} + +/// Successful or failed logical outcome of an operation. +#[derive(Debug, Clone, PartialEq)] +pub enum OperationOutcome { + /// Operation returned. `None` represents a declared no-return operation. + Returned(Option), + /// Operation failed with a protocol-level error. + Failed(ProtocolError), +} + +/// A transport-independent operation result. +#[derive(Debug, Clone, PartialEq)] +pub struct OperationResult { + /// Target corresponding to the result. + pub target: OperationTarget, + /// Logical outcome. + pub outcome: OperationOutcome, +} + +impl Object { + /// Validates ordered-set interface semantics. + pub fn validate(&self) -> Result<(), ValidationError> { + crate::validation::validate_object(self) + } +} + +impl InterfaceObservation { + /// Validates the object, descriptor structure, and their correspondence. + pub fn validate(&self) -> Result<(), ValidationError> { + crate::validation::validate_observation(self) + } +} + +impl InterfaceDescriptor { + /// Validates declaration structure, references, and absence of recursion. + pub fn validate(&self) -> Result<(), ValidationError> { + crate::validation::validate_descriptor(self) + } + + /// Validates a value against a type expression in this descriptor. + pub fn validate_value(&self, ty: &TypeExpr, value: &Value) -> Result<(), ValidationError> { + crate::validation::validate_root_value(self, ty, value) + } + + /// Validates a call target and arguments against this descriptor. + pub fn validate_call(&self, call: &OperationCall) -> Result<(), ValidationError> { + crate::validation::validate_call(self, call) + } + + /// Validates an operation's returned value against this descriptor. + pub fn validate_return( + &self, + operation: &str, + value: Option<&Value>, + ) -> Result<(), ValidationError> { + crate::validation::validate_return(self, operation, value) + } +} diff --git a/crates/wip-protocol/src/validation.rs b/crates/wip-protocol/src/validation.rs new file mode 100644 index 0000000..7852806 --- /dev/null +++ b/crates/wip-protocol/src/validation.rs @@ -0,0 +1,620 @@ +use std::collections::{HashMap, HashSet}; + +use crate::{ + InterfaceDescriptor, InterfaceObservation, JsonValue, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, + NameNamespace, Object, OperationCall, PathSegment, TypeDeclaration, TypeExpr, ValidationError, + ValidationErrorKind, Value, ValueKind, +}; + +pub(crate) fn validate_object(object: &Object) -> Result<(), ValidationError> { + let mut seen = HashSet::new(); + for interface in &object.interfaces { + if !seen.insert(interface.as_str()) { + return Err( + ValidationError::new(ValidationErrorKind::DuplicateInterface { + name: interface.clone(), + }) + .prepend(PathSegment::Interface(interface.clone())), + ); + } + } + Ok(()) +} + +pub(crate) fn validate_observation( + observation: &InterfaceObservation, +) -> Result<(), ValidationError> { + observation.object.validate()?; + + let mut descriptors = HashMap::new(); + for descriptor in &observation.descriptors { + if descriptors + .insert(descriptor.name.as_str(), descriptor) + .is_some() + { + return Err(ValidationError::new(ValidationErrorKind::DuplicateName { + namespace: NameNamespace::InterfaceDescriptor, + name: descriptor.name.clone(), + }) + .prepend(PathSegment::Interface(descriptor.name.clone()))); + } + descriptor.validate()?; + } + + for interface in &observation.object.interfaces { + if !descriptors.contains_key(interface.as_str()) { + return Err( + ValidationError::new(ValidationErrorKind::MissingInterfaceDescriptor { + name: interface.clone(), + }) + .prepend(PathSegment::Interface(interface.clone())), + ); + } + } + + let published: HashSet<&str> = observation + .object + .interfaces + .iter() + .map(String::as_str) + .collect(); + for descriptor in &observation.descriptors { + if !published.contains(descriptor.name.as_str()) { + return Err( + ValidationError::new(ValidationErrorKind::UnexpectedInterfaceDescriptor { + name: descriptor.name.clone(), + }) + .prepend(PathSegment::Interface(descriptor.name.clone())), + ); + } + } + + Ok(()) +} + +pub(crate) fn validate_descriptor(descriptor: &InterfaceDescriptor) -> Result<(), ValidationError> { + let declarations = declaration_map(descriptor)?; + + for declaration in &descriptor.declarations { + validate_declaration(declaration, &declarations).map_err(|error| { + error + .prepend(PathSegment::Declaration(declaration.name().to_owned())) + .prepend(PathSegment::Interface(descriptor.name.clone())) + })?; + } + + let mut operation_names = HashSet::new(); + for operation in &descriptor.operations { + if !operation_names.insert(operation.name.as_str()) { + return Err(ValidationError::new(ValidationErrorKind::DuplicateName { + namespace: NameNamespace::Operation, + name: operation.name.clone(), + }) + .prepend(PathSegment::Operation(operation.name.clone())) + .prepend(PathSegment::Interface(descriptor.name.clone()))); + } + + let mut parameter_names = HashSet::new(); + for parameter in &operation.parameters { + if !parameter_names.insert(parameter.name.as_str()) { + return Err(ValidationError::new(ValidationErrorKind::DuplicateName { + namespace: NameNamespace::Parameter, + name: parameter.name.clone(), + }) + .prepend(PathSegment::Parameter(parameter.name.clone())) + .prepend(PathSegment::Operation(operation.name.clone())) + .prepend(PathSegment::Interface(descriptor.name.clone()))); + } + validate_type_expr(¶meter.ty, &declarations).map_err(|error| { + error + .prepend(PathSegment::Parameter(parameter.name.clone())) + .prepend(PathSegment::Operation(operation.name.clone())) + .prepend(PathSegment::Interface(descriptor.name.clone())) + })?; + } + if let Some(returns) = &operation.returns { + validate_type_expr(&returns.ty, &declarations).map_err(|error| { + error + .prepend(PathSegment::Return) + .prepend(PathSegment::Operation(operation.name.clone())) + .prepend(PathSegment::Interface(descriptor.name.clone())) + })?; + } + } + + validate_no_cycles(descriptor, &declarations) +} + +fn declaration_map( + descriptor: &InterfaceDescriptor, +) -> Result, ValidationError> { + let mut declarations = HashMap::new(); + for declaration in &descriptor.declarations { + if declarations + .insert(declaration.name(), declaration) + .is_some() + { + return Err(ValidationError::new(ValidationErrorKind::DuplicateName { + namespace: NameNamespace::TypeDeclaration, + name: declaration.name().to_owned(), + }) + .prepend(PathSegment::Declaration(declaration.name().to_owned())) + .prepend(PathSegment::Interface(descriptor.name.clone()))); + } + } + Ok(declarations) +} + +fn validate_declaration( + declaration: &TypeDeclaration, + declarations: &HashMap<&str, &TypeDeclaration>, +) -> Result<(), ValidationError> { + match declaration { + TypeDeclaration::Record(record) => { + let mut names = HashSet::new(); + for field in &record.fields { + if !names.insert(field.name.as_str()) { + return Err(ValidationError::new(ValidationErrorKind::DuplicateName { + namespace: NameNamespace::Field, + name: field.name.clone(), + }) + .prepend(PathSegment::Field(field.name.clone()))); + } + validate_type_expr(&field.ty, declarations) + .map_err(|error| error.prepend(PathSegment::Field(field.name.clone())))?; + } + } + TypeDeclaration::Enum(enumeration) => { + let mut names = HashSet::new(); + for variant in &enumeration.variants { + if !names.insert(variant.name.as_str()) { + return Err(ValidationError::new(ValidationErrorKind::DuplicateName { + namespace: NameNamespace::EnumVariant, + name: variant.name.clone(), + }) + .prepend(PathSegment::Variant(variant.name.clone()))); + } + } + } + TypeDeclaration::Union(union) => { + let mut names = HashSet::new(); + for variant in &union.variants { + if !names.insert(variant.name.as_str()) { + return Err(ValidationError::new(ValidationErrorKind::DuplicateName { + namespace: NameNamespace::UnionVariant, + name: variant.name.clone(), + }) + .prepend(PathSegment::Variant(variant.name.clone()))); + } + validate_type_expr(&variant.ty, declarations) + .map_err(|error| error.prepend(PathSegment::Variant(variant.name.clone())))?; + } + } + } + Ok(()) +} + +fn validate_type_expr( + ty: &TypeExpr, + declarations: &HashMap<&str, &TypeDeclaration>, +) -> Result<(), ValidationError> { + match ty { + TypeExpr::List(item) => validate_type_expr(item, declarations), + TypeExpr::Entry { key, value } => { + validate_type_expr(key, declarations) + .map_err(|error| error.prepend(PathSegment::EntryKey))?; + validate_type_expr(value, declarations) + .map_err(|error| error.prepend(PathSegment::EntryValue)) + } + TypeExpr::Named(name) if !declarations.contains_key(name.as_str()) => { + Err(ValidationError::new(ValidationErrorKind::UnknownType { + name: name.clone(), + })) + } + _ => Ok(()), + } +} + +fn validate_no_cycles( + descriptor: &InterfaceDescriptor, + declarations: &HashMap<&str, &TypeDeclaration>, +) -> Result<(), ValidationError> { + let mut state: HashMap<&str, VisitState> = HashMap::new(); + let mut stack = Vec::new(); + + for declaration in &descriptor.declarations { + visit_declaration( + declaration.name(), + descriptor, + declarations, + &mut state, + &mut stack, + )?; + } + Ok(()) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum VisitState { + Visiting, + Visited, +} + +fn visit_declaration<'a>( + name: &'a str, + descriptor: &InterfaceDescriptor, + declarations: &HashMap<&'a str, &'a TypeDeclaration>, + state: &mut HashMap<&'a str, VisitState>, + stack: &mut Vec<&'a str>, +) -> Result<(), ValidationError> { + match state.get(name) { + Some(VisitState::Visited) => return Ok(()), + Some(VisitState::Visiting) => { + let start = stack.iter().position(|entry| *entry == name).unwrap_or(0); + let mut cycle: Vec = stack[start..] + .iter() + .map(|entry| (*entry).to_owned()) + .collect(); + cycle.push(name.to_owned()); + return Err( + ValidationError::new(ValidationErrorKind::RecursiveType { cycle }) + .prepend(PathSegment::Declaration(name.to_owned())) + .prepend(PathSegment::Interface(descriptor.name.clone())), + ); + } + None => {} + } + + state.insert(name, VisitState::Visiting); + stack.push(name); + let mut references = Vec::new(); + collect_declaration_references(declarations[name], &mut references); + for reference in references { + visit_declaration(reference, descriptor, declarations, state, stack)?; + } + stack.pop(); + state.insert(name, VisitState::Visited); + Ok(()) +} + +fn collect_declaration_references<'a>( + declaration: &'a TypeDeclaration, + references: &mut Vec<&'a str>, +) { + match declaration { + TypeDeclaration::Record(record) => { + for field in &record.fields { + collect_type_references(&field.ty, references); + } + } + TypeDeclaration::Enum(_) => {} + TypeDeclaration::Union(union) => { + for variant in &union.variants { + collect_type_references(&variant.ty, references); + } + } + } +} + +fn collect_type_references<'a>(ty: &'a TypeExpr, references: &mut Vec<&'a str>) { + match ty { + TypeExpr::List(item) => collect_type_references(item, references), + TypeExpr::Entry { key, value } => { + collect_type_references(key, references); + collect_type_references(value, references); + } + TypeExpr::Named(name) => references.push(name), + _ => {} + } +} + +pub(crate) fn validate_root_value( + descriptor: &InterfaceDescriptor, + ty: &TypeExpr, + value: &Value, +) -> Result<(), ValidationError> { + descriptor.validate()?; + let declarations = declaration_map(descriptor)?; + validate_value(ty, value, &declarations) + .map_err(|error| error.prepend(PathSegment::Interface(descriptor.name.clone()))) +} + +fn validate_value( + ty: &TypeExpr, + value: &Value, + declarations: &HashMap<&str, &TypeDeclaration>, +) -> Result<(), ValidationError> { + match ty { + TypeExpr::String => expect_kind(value, ValueKind::String), + TypeExpr::Boolean => expect_kind(value, ValueKind::Boolean), + TypeExpr::Integer => match value { + Value::Integer(integer) if (MIN_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(integer) => { + Ok(()) + } + Value::Integer(integer) => Err(ValidationError::new( + ValidationErrorKind::IntegerOutOfRange { value: *integer }, + )), + _ => expect_kind(value, ValueKind::Integer), + }, + TypeExpr::Number => match value { + Value::Number(number) if number.is_finite() => Ok(()), + Value::Number(_) => Err(ValidationError::new(ValidationErrorKind::NonFiniteNumber)), + _ => expect_kind(value, ValueKind::Number), + }, + TypeExpr::Bytes => expect_kind(value, ValueKind::Bytes), + TypeExpr::List(item_type) => match value { + Value::List(items) => { + for (index, item) in items.iter().enumerate() { + validate_value(item_type, item, declarations) + .map_err(|error| error.prepend(PathSegment::Index(index)))?; + } + Ok(()) + } + _ => expect_kind(value, ValueKind::List), + }, + TypeExpr::Entry { + key: key_type, + value: value_type, + } => match value { + Value::Entry { key, value } => { + validate_value(key_type, key, declarations) + .map_err(|error| error.prepend(PathSegment::EntryKey))?; + validate_value(value_type, value, declarations) + .map_err(|error| error.prepend(PathSegment::EntryValue)) + } + _ => expect_kind(value, ValueKind::Entry), + }, + TypeExpr::Json => match value { + Value::Json(json) => validate_json(json), + _ => expect_kind(value, ValueKind::Json), + }, + TypeExpr::Named(name) => { + validate_declared_value(declarations[name.as_str()], value, declarations) + } + } +} + +fn validate_declared_value( + declaration: &TypeDeclaration, + value: &Value, + declarations: &HashMap<&str, &TypeDeclaration>, +) -> Result<(), ValidationError> { + match declaration { + TypeDeclaration::Record(record) => match value { + Value::Record(fields) => { + let mut supplied = HashMap::new(); + for field in fields { + if supplied.insert(field.name.as_str(), &field.value).is_some() { + return Err(ValidationError::new(ValidationErrorKind::DuplicateName { + namespace: NameNamespace::RecordValueField, + name: field.name.clone(), + }) + .prepend(PathSegment::Field(field.name.clone()))); + } + } + + for field in fields { + let Some(declared) = record + .fields + .iter() + .find(|declared| declared.name == field.name) + else { + return Err(ValidationError::new(ValidationErrorKind::UnknownField { + name: field.name.clone(), + }) + .prepend(PathSegment::Field(field.name.clone()))); + }; + validate_value(&declared.ty, &field.value, declarations) + .map_err(|error| error.prepend(PathSegment::Field(field.name.clone())))?; + } + + for declared in &record.fields { + if !declared.optional && !supplied.contains_key(declared.name.as_str()) { + return Err(ValidationError::new(ValidationErrorKind::MissingField { + name: declared.name.clone(), + }) + .prepend(PathSegment::Field(declared.name.clone()))); + } + } + Ok(()) + } + _ => expect_kind(value, ValueKind::Record), + }, + TypeDeclaration::Enum(enumeration) => match value { + Value::Enum(variant) + if enumeration + .variants + .iter() + .any(|declared| declared.name == *variant) => + { + Ok(()) + } + Value::Enum(variant) => { + Err(ValidationError::new(ValidationErrorKind::UnknownVariant { + name: variant.clone(), + }) + .prepend(PathSegment::Variant(variant.clone()))) + } + _ => expect_kind(value, ValueKind::Enum), + }, + TypeDeclaration::Union(union) => match value { + Value::Union { variant, value } => { + let Some(declared) = union + .variants + .iter() + .find(|declared| declared.name == *variant) + else { + return Err(ValidationError::new(ValidationErrorKind::UnknownVariant { + name: variant.clone(), + }) + .prepend(PathSegment::Variant(variant.clone()))); + }; + validate_value(&declared.ty, value, declarations) + .map_err(|error| error.prepend(PathSegment::Variant(variant.clone()))) + } + _ => expect_kind(value, ValueKind::Union), + }, + } +} + +fn expect_kind(value: &Value, expected: ValueKind) -> Result<(), ValidationError> { + if value.kind() == expected { + Ok(()) + } else { + Err(ValidationError::new(ValidationErrorKind::TypeMismatch { + expected, + actual: value.kind(), + })) + } +} + +fn validate_json(value: &JsonValue) -> Result<(), ValidationError> { + match value { + JsonValue::Integer(integer) if !(MIN_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(integer) => { + Err(ValidationError::new( + ValidationErrorKind::IntegerOutOfRange { value: *integer }, + )) + } + JsonValue::Number(number) if !number.is_finite() => { + Err(ValidationError::new(ValidationErrorKind::NonFiniteNumber)) + } + JsonValue::Array(items) => { + for (index, item) in items.iter().enumerate() { + validate_json(item).map_err(|error| error.prepend(PathSegment::Index(index)))?; + } + Ok(()) + } + JsonValue::Object(members) => { + let mut names = HashSet::new(); + for member in members { + if !names.insert(member.name.as_str()) { + return Err(ValidationError::new(ValidationErrorKind::DuplicateName { + namespace: NameNamespace::JsonMember, + name: member.name.clone(), + }) + .prepend(PathSegment::JsonMember(member.name.clone()))); + } + validate_json(&member.value) + .map_err(|error| error.prepend(PathSegment::JsonMember(member.name.clone())))?; + } + Ok(()) + } + _ => Ok(()), + } +} + +pub(crate) fn validate_call( + descriptor: &InterfaceDescriptor, + call: &OperationCall, +) -> Result<(), ValidationError> { + descriptor.validate()?; + if call.target.interface != descriptor.name { + return Err( + ValidationError::new(ValidationErrorKind::InterfaceMismatch { + expected: descriptor.name.clone(), + actual: call.target.interface.clone(), + }) + .prepend(PathSegment::Interface(call.target.interface.clone())), + ); + } + let Some(operation) = descriptor + .operations + .iter() + .find(|operation| operation.name == call.target.operation) + else { + return Err(ValidationError::new(ValidationErrorKind::UnknownOperation { + name: call.target.operation.clone(), + }) + .prepend(PathSegment::Operation(call.target.operation.clone())) + .prepend(PathSegment::Interface(descriptor.name.clone()))); + }; + + let mut supplied = HashMap::new(); + for argument in &call.arguments { + if supplied + .insert(argument.name.as_str(), &argument.value) + .is_some() + { + return Err(ValidationError::new(ValidationErrorKind::DuplicateName { + namespace: NameNamespace::Argument, + name: argument.name.clone(), + }) + .prepend(PathSegment::Parameter(argument.name.clone())) + .prepend(PathSegment::Operation(operation.name.clone())) + .prepend(PathSegment::Interface(descriptor.name.clone()))); + } + } + + let declarations = declaration_map(descriptor)?; + for argument in &call.arguments { + let Some(parameter) = operation + .parameters + .iter() + .find(|parameter| parameter.name == argument.name) + else { + return Err(ValidationError::new(ValidationErrorKind::UnknownParameter { + name: argument.name.clone(), + }) + .prepend(PathSegment::Parameter(argument.name.clone())) + .prepend(PathSegment::Operation(operation.name.clone())) + .prepend(PathSegment::Interface(descriptor.name.clone()))); + }; + validate_value(¶meter.ty, &argument.value, &declarations).map_err(|error| { + error + .prepend(PathSegment::Parameter(argument.name.clone())) + .prepend(PathSegment::Operation(operation.name.clone())) + .prepend(PathSegment::Interface(descriptor.name.clone())) + })?; + } + + for parameter in &operation.parameters { + if !parameter.optional && !supplied.contains_key(parameter.name.as_str()) { + return Err(ValidationError::new(ValidationErrorKind::MissingParameter { + name: parameter.name.clone(), + }) + .prepend(PathSegment::Parameter(parameter.name.clone())) + .prepend(PathSegment::Operation(operation.name.clone())) + .prepend(PathSegment::Interface(descriptor.name.clone()))); + } + } + Ok(()) +} + +pub(crate) fn validate_return( + descriptor: &InterfaceDescriptor, + operation_name: &str, + value: Option<&Value>, +) -> Result<(), ValidationError> { + descriptor.validate()?; + let Some(operation) = descriptor + .operations + .iter() + .find(|operation| operation.name == operation_name) + else { + return Err(ValidationError::new(ValidationErrorKind::UnknownOperation { + name: operation_name.to_owned(), + }) + .prepend(PathSegment::Operation(operation_name.to_owned())) + .prepend(PathSegment::Interface(descriptor.name.clone()))); + }; + + match (&operation.returns, value) { + (None, None) => Ok(()), + (None, Some(_)) => Err(ValidationError::new( + ValidationErrorKind::UnexpectedReturnValue, + )), + (Some(_), None) => Err(ValidationError::new( + ValidationErrorKind::MissingReturnValue, + )), + (Some(returns), Some(value)) => { + let declarations = declaration_map(descriptor)?; + validate_value(&returns.ty, value, &declarations) + } + } + .map_err(|error| { + error + .prepend(PathSegment::Return) + .prepend(PathSegment::Operation(operation.name.clone())) + .prepend(PathSegment::Interface(descriptor.name.clone())) + }) +} diff --git a/crates/wip-protocol/tests/validation.rs b/crates/wip-protocol/tests/validation.rs new file mode 100644 index 0000000..9495df6 --- /dev/null +++ b/crates/wip-protocol/tests/validation.rs @@ -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 + ); +}