diff --git a/crates/wip-protocol/src/error.rs b/crates/wip-protocol/src/error.rs index dba04b1..a093723 100644 --- a/crates/wip-protocol/src/error.rs +++ b/crates/wip-protocol/src/error.rs @@ -8,79 +8,65 @@ pub enum NameNamespace { TypeDeclaration, /// Operations in an interface descriptor. Operation, - /// Fields in a record declaration. + /// Fields in an inline or named record type. 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, + /// Cases in an enum type. + EnumCase, + /// Cases in a union type. + UnionCase, + /// Child object names beneath one tree node. + ChildObject, } -/// Structural kind of a logical protocol value. +/// Structural kind of a schema-neutral logical value. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ValueKind { - /// String value. - String, + /// Unit value. + Unit, /// Boolean value. Boolean, /// Integer value. Integer, /// Floating-point number value. Number, + /// String value. + String, /// Byte sequence. Bytes, - /// List value. - List, - /// Key/value entry. - Entry, /// Record value. Record, - /// Enum value. - Enum, - /// Tagged-union value. - Union, - /// Logical JSON value. - Json, + /// List value. + List, } /// A typed segment identifying a declaration or value location. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PathSegment { - /// Interface descriptor. - Interface(String), + /// Descriptor root. + Descriptor, /// Named type declaration. Declaration(String), /// Operation declaration or call. Operation(String), /// Record field declaration or value. Field(String), - /// Operation parameter declaration or argument. + /// Operation parameter or argument. Parameter(String), - /// Enum or union variant. - Variant(String), - /// List or JSON array index. + /// Enum or union case. + Case(String), + /// List index. Index(usize), - /// Entry key. - EntryKey, - /// Entry value. - EntryValue, /// Operation return value. Return, - /// JSON object member. - JsonMember(String), + /// Object at a Worldspace path. + Object(String), + /// Child object in a tree response. + Child(String), } -/// Machine-readable reason why protocol validation failed. +/// Machine-readable reason why logical protocol validation failed. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ValidationErrorKind { /// A name occurred more than once in one namespace. @@ -117,11 +103,15 @@ pub enum ValidationErrorKind { /// Missing field name. name: String, }, - /// An enum or union value selected an undeclared variant. - UnknownVariant { - /// Unknown variant name. + /// An enum or union selected an undeclared case. + UnknownCase { + /// Unknown case name. name: String, }, + /// A payload-bearing union case omitted `value`. + MissingUnionPayload, + /// A payload-free union case supplied `value`. + UnexpectedUnionPayload, /// An operation call contains an undeclared argument. UnknownParameter { /// Unknown parameter name. @@ -132,44 +122,66 @@ pub enum ValidationErrorKind { /// Missing parameter name. name: String, }, - /// An integer is outside the JavaScript-lossless range. + /// An operation is not declared by the descriptor. + UnknownOperation { + /// Unknown operation name. + name: String, + }, + /// An integer is outside the interoperable safe range. IntegerOutOfRange { /// Rejected integer. value: i64, }, /// A numeric value is NaN or infinite. NonFiniteNumber, + /// A JSON-interpreted value tree contains protocol bytes. + BytesInJson, + /// An object name is not a valid root or non-root path segment. + InvalidObjectName { + /// Rejected object name. + name: String, + }, /// An object's ordered interface set contains a duplicate. DuplicateInterface { - /// Duplicate interface name. - name: String, + /// Duplicate opaque interface reference. + reference: String, }, - /// An observation omits a descriptor named by its object. - MissingInterfaceDescriptor { - /// Missing interface name. - name: String, + /// A string is not a canonical absolute Worldspace path. + InvalidPath { + /// Rejected path. + path: 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. + /// An object name does not match the path where it was observed. + ObjectNameMismatch { + /// Name required by the observation path. expected: String, - /// Call target interface name. + /// Name supplied by the object. actual: String, }, - /// An operation is not declared by the interface. - UnknownOperation { - /// Unknown operation name. - name: String, + /// Equivalent ref/validator observations disagree on object representation. + InconsistentObjectObservation { + /// Shared opaque object reference. + reference: String, + }, + /// A `fetch_tree` request used a zero limit. + ZeroTreeLimit, + /// A tree response exceeds the requested maximum depth. + TreeDepthExceeded { + /// Requested maximum depth. + maximum: u32, + }, + /// A tree response exceeds the requested maximum node count. + TreeLimitExceeded { + /// Requested maximum node count. + limit: u32, + }, + /// A response refers to a different interface than its request. + InterfaceResponseMismatch { + /// Requested opaque reference. + expected: String, + /// Returned opaque reference. + actual: 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. @@ -211,30 +223,44 @@ impl Display for ValidationError { impl Error for ValidationError {} -/// Stable category of an error returned by a protocol host runtime. +/// Stable protocol failure code returned by a host or produced by a client. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProtocolErrorCategory { +pub enum ProtocolErrorCode { /// The request is structurally invalid. InvalidRequest, /// The target object is not published. - ObjectNotFound, - /// The target interface is not implemented by the object. + NotFound, + /// The interface reference is not currently published. InterfaceNotFound, - /// The target operation is not declared by the interface. + /// The selected interface is not on the current object. + InterfaceMismatch, + /// The selected operation is not declared by the current interface. OperationNotFound, /// Supplied operation arguments are invalid. InvalidArguments, - /// The implementation rejected the call for a domain reason. - OperationFailed, + /// A host result does not satisfy the declaration. + InvalidResult, + /// The operation requires an object validator and none was supplied. + ValidatorRequired, + /// The supplied object validator does not match current state. + ValidatorMismatch, + /// A mutable interface requires its validator and none was supplied. + InterfaceValidatorRequired, + /// The supplied interface validator does not match the descriptor. + InterfaceValidatorMismatch, + /// A published operation is not permitted for the caller. + PermissionDenied, + /// A bounded protocol resource limit was exceeded. + ResourceLimitExceeded, /// The host encountered an internal failure. Internal, } -/// Transport-independent protocol failure returned by a host runtime. +/// Transport-independent protocol failure. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProtocolError { - /// Stable programmatic category. - pub category: ProtocolErrorCategory, - /// Human-readable diagnostic. Consumers must not branch on this text. + /// Stable programmatic code. Consumers must branch on this field. + pub code: ProtocolErrorCode, + /// Bounded human-readable diagnostic. Consumers must not branch on it. pub message: String, } diff --git a/crates/wip-protocol/src/lib.rs b/crates/wip-protocol/src/lib.rs index c6275f1..f10bbb3 100644 --- a/crates/wip-protocol/src/lib.rs +++ b/crates/wip-protocol/src/lib.rs @@ -1,9 +1,32 @@ -//! Transport-independent logical types for the Web Interface Protocol (WIP). +//! Canonical transport-independent logical types for the Web Interface Protocol. //! -//! 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. +//! This crate follows the Core Model and `3-protocol.md` without assigning HTTP +//! routes, methods, headers, URL encodings, or JSON envelopes. The correspondence +//! between canonical logical concepts and this crate is direct: +//! +//! | Canonical protocol concept | Rust API | +//! | --- | --- | +//! | Object projection and ordered interface set | [`Object`] | +//! | Optional object identity / state precondition | `Object::ref` / [`Object::validator`] | +//! | Interface Descriptor | [`InterfaceDescriptor`] | +//! | Named declaration and all schema expressions | [`TypeDeclaration`] / [`TypeExpr`] | +//! | Schema-neutral primitive/composite value | [`Value`] | +//! | `fetch` | [`FetchRequest`] / [`FetchResponse`] | +//! | `fetch_tree` | [`FetchTreeRequest`] / [`FetchTreeResponse`] | +//! | `fetch_interface` | [`FetchInterfaceRequest`] / [`FetchInterfaceResponse`] | +//! | Object and interface call preconditions | [`Target`] / [`InterfaceTarget`] | +//! | `call_operation` | [`CallOperationRequest`] / [`CallOperationResponse`] | +//! | Stable failure taxonomy | [`ProtocolErrorCode`] / [`ProtocolError`] | +//! +//! Paths, object refs, interface refs, and validators remain separate concepts. +//! They are represented by ordinary protocol strings/bytes in the corresponding +//! fields. In particular, object refs are optional, operation targeting uses a +//! path, and this crate defines no interaction that fetches an object by ref. +//! +//! [`Value`] contains only the canonical raw value kinds. A descriptor interprets +//! enum values as strings, entries as canonical-path strings, and unions as +//! records with a `$case` string plus an optional `value` payload. JSON uses the +//! same value tree (`Unit` is JSON null) and rejects bytes. #![deny(missing_docs)] @@ -12,19 +35,33 @@ mod model; mod validation; pub use error::{ - NameNamespace, PathSegment, ProtocolError, ProtocolErrorCategory, ValidationError, + NameNamespace, PathSegment, ProtocolError, ProtocolErrorCode, 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, + CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest, + FetchInterfaceResponse, FetchRequest, FetchResponse, FetchTreeRequest, FetchTreeResponse, + FieldDeclaration, InterfaceDescriptor, InterfaceTarget, Object, ObjectTreeNode, + OperationDeclaration, ParameterDeclaration, ProtocolResult, ReturnDeclaration, Target, + TypeDeclaration, TypeExpr, UnionCase, Value, }; -/// Largest integer that is exactly interoperable with a JavaScript `Number`. +/// Validates a canonical absolute Worldspace path. +pub fn validate_path(path: &str) -> Result<(), ValidationError> { + validation::validate_path(path) +} + +/// Canonical format identifier for the first Interface Descriptor format. +pub const INTERFACE_FORMAT_V1: &str = "wip-interface/1"; + +/// Discriminator field used by a union's raw record value. +pub const UNION_CASE_FIELD: &str = "$case"; + +/// Optional payload field used by a union's raw record value. +pub const UNION_VALUE_FIELD: &str = "value"; + +/// Largest integer 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`. +/// Smallest integer 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 index 88a64a9..f678ab6 100644 --- a/crates/wip-protocol/src/model.rs +++ b/crates/wip-protocol/src/model.rs @@ -1,6 +1,8 @@ +use std::collections::BTreeMap; + use crate::{ProtocolError, ValidationError}; -/// Human-readable documentation attached to a protocol declaration. +/// Human-readable plain-text documentation attached to a protocol declaration. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Documentation { /// A short, self-contained description. @@ -9,242 +11,194 @@ pub struct 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 protocol projection currently published at one Worldspace path. /// -/// The vector order is significant. Call [`Object::validate`] before -/// publication to reject duplicate interface names. +/// `name` is a path segment, `interfaces` is an ordered set of opaque interface +/// references, `r#ref` is optional object identity, and `validator` is an +/// independent optional observation precondition. In particular, neither the +/// path nor `r#ref` is a mandatory object identifier. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Object { - /// Logical object identity. - pub id: ObjectId, - /// Interface names in publication order. + /// Path-segment name. The root object's name is empty. + pub name: String, + /// Short public description of the object. + pub description: Option, + /// Opaque interface references in host 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, + /// Optional opaque identity within one client-facing Worldspace. + pub r#ref: Option, + /// Optional opaque validator for the object's observed state. + pub validator: Option>, } /// Transport-independent description of one interface. -#[derive(Debug, Clone, PartialEq)] +/// +/// Interface identity is supplied by interaction fields such as +/// [`FetchInterfaceResponse::interface`], not embedded in the descriptor. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct InterfaceDescriptor { - /// Interface name. - pub name: String, + /// Descriptor format version understood by clients and hosts. + pub format: String, /// Optional interface documentation. pub documentation: Option, - /// Named type declarations local to this descriptor. - pub declarations: Vec, + /// Named declarations local to this descriptor. + pub types: 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 { +/// A descriptor-local named type declaration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TypeDeclaration { /// Descriptor-local type name. pub name: String, - /// Optional record documentation. + /// Optional type documentation. pub documentation: Option, - /// Fields in declaration order. - pub fields: Vec, + /// Definition associated with `name`. + pub definition: TypeExpr, +} + +/// A type expression used by declarations, fields, parameters, and returns. +/// +/// [`TypeExpr::Named`] references are resolved only within the containing +/// descriptor. Recursive references, including recursion through lists, +/// records, and unions, are invalid. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TypeExpr { + /// A value carrying no data. + Unit, + /// A boolean. + Boolean, + /// A JavaScript-lossless signed integer. + Integer, + /// A finite IEEE-754 binary64 number. + Number, + /// A Unicode string. + String, + /// An opaque byte sequence. + Bytes, + /// A schema-opaque JSON-compatible value tree. + Json, + /// A canonical absolute Worldspace path represented by a string value. + Entry, + /// A descriptor-local named declaration. + Named { + /// Referenced declaration name. + name: String, + }, + /// A record with named fields. + Record { + /// Fields in declaration order. + fields: Vec, + }, + /// An ordered list. + List { + /// Type of every list item. + items: Box, + }, + /// A closed set of payload-free cases represented by strings. + Enum { + /// Cases in declaration order. + cases: Vec, + }, + /// A discriminated union represented by a record. + Union { + /// Cases in declaration order. + cases: Vec, + }, } /// Declaration of a record field. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct FieldDeclaration { /// Field name. pub name: String, + /// Whether the field must be present. + pub required: bool, /// 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, + pub r#type: TypeExpr, } -/// Declaration of an enumeration. +/// One payload-free enum case. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct EnumDeclaration { - /// Descriptor-local type name. +pub struct EnumCase { + /// Case name. pub name: String, - /// Optional enum documentation. + /// Optional case documentation. pub documentation: Option, - /// Allowed variants in declaration order. - pub variants: Vec, } -/// Declaration of one enumeration variant. +/// One union case with zero or one payload. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct EnumVariantDeclaration { - /// Variant name. +pub struct UnionCase { + /// Case name. pub name: String, - /// Optional variant documentation. + /// Optional case 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), + /// Optional payload type. + pub payload: Option, } /// Declaration of one interface operation. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct OperationDeclaration { - /// Operation name, unique within its interface. + /// Operation name, unique within its descriptor. 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, + /// Return declaration. No-data operations return [`TypeExpr::Unit`]. + pub returns: ReturnDeclaration, } /// Declaration of one operation parameter. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ParameterDeclaration { /// Parameter name. pub name: String, + /// Whether the parameter must be present. + pub required: bool, /// 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, + pub r#type: TypeExpr, } /// Declaration of an operation return value. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ReturnDeclaration { /// Optional return-value documentation. pub documentation: Option, /// Return value type. - pub ty: TypeExpr, + pub r#type: TypeExpr, } -/// A logical protocol value. +/// A schema-neutral logical protocol value. /// -/// There is no general `Null` variant: optional record fields and operation -/// parameters are omitted. JSON null remains available inside [`Value::Json`]. +/// Enum cases, union cases, entries, and JSON are interpretations imposed by a +/// descriptor. They are deliberately not variants of this enum. Record key +/// ordering is not protocol-significant. #[derive(Debug, Clone, PartialEq)] pub enum Value { - /// A UTF-8 string. - String(String), + /// A value carrying no data; it also represents JSON null under `Json`. + Unit, /// A boolean. Boolean(bool), - /// An integer, validated against the JavaScript safe integer range. + /// A JavaScript-lossless integer. Integer(i64), - /// A number, validated to be finite. + /// A finite IEEE-754 binary64 number. Number(f64), + /// A Unicode string. + String(String), /// An opaque byte sequence. Bytes(Vec), + /// String-keyed fields with order-insensitive semantics. + Record(BTreeMap), /// 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 { @@ -252,132 +206,219 @@ impl Value { #[must_use] pub fn kind(&self) -> crate::ValueKind { match self { - Self::String(_) => crate::ValueKind::String, + Self::Unit => crate::ValueKind::Unit, Self::Boolean(_) => crate::ValueKind::Boolean, Self::Integer(_) => crate::ValueKind::Integer, Self::Number(_) => crate::ValueKind::Number, + Self::String(_) => crate::ValueKind::String, 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, + Self::List(_) => crate::ValueKind::List, } } } -/// 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. +/// Request for the object currently published at one path. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct OperationTarget { - /// Target object. - pub object: ObjectId, - /// Target interface name. +pub struct FetchRequest { + /// Canonical absolute Worldspace path. + pub path: String, +} + +/// Successful `fetch` response. +pub type FetchResponse = Object; + +/// Request for a bounded breadth-first object-tree observation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FetchTreeRequest { + /// Canonical absolute path of the tree root. + pub path: String, + /// Maximum depth, where the requested object has depth zero. + pub depth: u32, + /// Maximum total node count, including the root. Must be nonzero. + pub limit: u32, +} + +/// One node in a tree observation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObjectTreeNode { + /// Object observed at this tree position. + pub object: Object, + /// Direct children in host-defined traversal order. + pub children: Vec, +} + +/// Successful `fetch_tree` response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FetchTreeResponse { + /// Observed tree rooted at the requested path. + pub root: ObjectTreeNode, + /// Whether public indexable children were omitted by depth or limit. + pub truncated: bool, +} + +/// Request for the current descriptor of one opaque interface reference. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FetchInterfaceRequest { + /// Opaque interface reference obtained from an object. pub interface: String, - /// Target operation name. +} + +/// Successful `fetch_interface` response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FetchInterfaceResponse { + /// Opaque interface reference corresponding to the request. + pub interface: String, + /// Current validated descriptor. + pub descriptor: InterfaceDescriptor, + /// Optional opaque validator for this descriptor observation. + pub validator: Option>, +} + +/// Object portion of an operation target. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Target { + /// Canonical absolute path of the target object. + pub path: String, + /// Optional validator copied from the object observation. + pub validator: Option>, +} + +/// Interface portion of an operation target. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InterfaceTarget { + /// Opaque interface reference selected from the object's interface set. + pub reference: String, + /// Optional validator copied from the interface observation. + pub validator: Option>, +} + +/// Transport-independent request to execute one operation. +#[derive(Debug, Clone, PartialEq)] +pub struct CallOperationRequest { + /// Path and optional object-state precondition. + pub target: Target, + /// Interface reference and its independent optional precondition. + pub interface: InterfaceTarget, + /// Operation name within the selected descriptor. pub operation: String, + /// Named arguments; map iteration order has no protocol meaning. + pub arguments: BTreeMap, } -/// A transport-independent operation call. +/// Successful operation response. #[derive(Debug, Clone, PartialEq)] -pub struct OperationCall { - /// Logical call target. - pub target: OperationTarget, - /// Named arguments. Optional arguments are represented by omission. - pub arguments: Vec, +pub struct CallOperationResponse { + /// Application result validated against the operation return declaration. + pub result: Value, + /// Optional validator for the object state observed after execution. + pub validator: Option>, } -/// 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, +/// Logical outcome of any core interaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProtocolResult { + /// The interaction succeeded. + Success(T), + /// The interaction failed at the protocol boundary. + Failure(ProtocolError), } impl Object { - /// Validates ordered-set interface semantics. + /// Validates the object-independent name and ordered-interface-set rules. 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) + /// Validates this object as the response observed at `path`. + pub fn validate_at_path(&self, path: &str) -> Result<(), ValidationError> { + crate::validation::validate_object_at_path(self, path) + } + + /// Validates equal representations for a shared ref and present validator. + pub fn validate_consistency_with(&self, other: &Self) -> Result<(), ValidationError> { + crate::validation::validate_object_consistency(self, other) } } impl InterfaceDescriptor { - /// Validates declaration structure, references, and absence of recursion. + /// Validates declaration structure, named references, and absence of cycles. 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 raw value against a type expression in this descriptor. + pub fn validate_value(&self, r#type: &TypeExpr, value: &Value) -> Result<(), ValidationError> { + crate::validation::validate_root_value(self, r#type, 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( + /// Validates named arguments for one declared operation. + pub fn validate_arguments( &self, operation: &str, - value: Option<&Value>, + arguments: &BTreeMap, ) -> Result<(), ValidationError> { - crate::validation::validate_return(self, operation, value) + crate::validation::validate_arguments(self, operation, arguments) + } + + /// Validates one operation result against its return declaration. + pub fn validate_result(&self, operation: &str, result: &Value) -> Result<(), ValidationError> { + crate::validation::validate_result(self, operation, result) + } + + /// Validates the path, operation, and arguments in a call request. + pub fn validate_call(&self, request: &CallOperationRequest) -> Result<(), ValidationError> { + crate::validation::validate_call(self, request) + } +} + +impl FetchRequest { + /// Validates the request path. + pub fn validate(&self) -> Result<(), ValidationError> { + crate::validation::validate_path(&self.path) + } + + /// Validates an object returned for this request. + pub fn validate_response(&self, response: &FetchResponse) -> Result<(), ValidationError> { + response.validate_at_path(&self.path) + } +} + +impl FetchTreeRequest { + /// Validates the request path and nonzero node limit. + pub fn validate(&self) -> Result<(), ValidationError> { + crate::validation::validate_fetch_tree_request(self) + } +} + +impl FetchTreeResponse { + /// Validates tree shape, names, depth, and node limit for `request`. + pub fn validate_for(&self, request: &FetchTreeRequest) -> Result<(), ValidationError> { + crate::validation::validate_fetch_tree_response(self, request) + } +} + +impl FetchInterfaceResponse { + /// Validates request correspondence and descriptor semantics. + pub fn validate_for(&self, request: &FetchInterfaceRequest) -> Result<(), ValidationError> { + crate::validation::validate_fetch_interface_response(self, request) + } +} + +impl CallOperationRequest { + /// Validates this call against the descriptor selected by `interface`. + pub fn validate_with(&self, descriptor: &InterfaceDescriptor) -> Result<(), ValidationError> { + descriptor.validate_call(self) + } +} + +impl CallOperationResponse { + /// Validates this result against the named operation in `descriptor`. + pub fn validate_for( + &self, + descriptor: &InterfaceDescriptor, + operation: &str, + ) -> Result<(), ValidationError> { + descriptor.validate_result(operation, &self.result) } } diff --git a/crates/wip-protocol/src/validation.rs b/crates/wip-protocol/src/validation.rs index e38eaa8..e48c956 100644 --- a/crates/wip-protocol/src/validation.rs +++ b/crates/wip-protocol/src/validation.rs @@ -1,85 +1,119 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use crate::{ - InterfaceDescriptor, InterfaceObservation, JsonValue, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, - NameNamespace, Object, OperationCall, PathSegment, TypeDeclaration, TypeExpr, ValidationError, - ValidationErrorKind, Value, ValueKind, + CallOperationRequest, FetchInterfaceRequest, FetchInterfaceResponse, FetchTreeRequest, + FetchTreeResponse, InterfaceDescriptor, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NameNamespace, + Object, ObjectTreeNode, PathSegment, TypeDeclaration, TypeExpr, UNION_CASE_FIELD, + UNION_VALUE_FIELD, ValidationError, ValidationErrorKind, Value, ValueKind, }; pub(crate) fn validate_object(object: &Object) -> Result<(), ValidationError> { + validate_object_name(&object.name, true)?; + 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(), + reference: interface.clone(), }) - .prepend(PathSegment::Interface(interface.clone())), + .prepend(PathSegment::Field("interfaces".into())), ); } } Ok(()) } -pub(crate) fn validate_observation( - observation: &InterfaceObservation, -) -> Result<(), ValidationError> { - observation.object.validate()?; +fn validate_object_name(name: &str, allow_root: bool) -> Result<(), ValidationError> { + let valid = + (allow_root || !name.is_empty()) && !name.contains('/') && name != "." && name != ".."; + if valid { + Ok(()) + } else { + Err(ValidationError::new( + ValidationErrorKind::InvalidObjectName { + name: name.to_owned(), + }, + )) + } +} - 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(), +pub(crate) fn validate_object_at_path(object: &Object, path: &str) -> Result<(), ValidationError> { + validate_path(path)?; + object + .validate() + .map_err(|error| error.prepend(PathSegment::Object(path.to_owned())))?; + + let expected = if path == "/" { + "" + } else { + path.rsplit('/') + .next() + .expect("non-root path has a segment") + }; + if object.name == expected { + Ok(()) + } else { + Err( + ValidationError::new(ValidationErrorKind::ObjectNameMismatch { + expected: expected.to_owned(), + actual: object.name.clone(), }) - .prepend(PathSegment::Interface(descriptor.name.clone()))); - } - descriptor.validate()?; + .prepend(PathSegment::Object(path.to_owned())), + ) } +} - 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())), - ); - } +pub(crate) fn validate_object_consistency( + left: &Object, + right: &Object, +) -> Result<(), ValidationError> { + left.validate()?; + right.validate()?; + if let (Some(left_ref), Some(right_ref)) = (&left.r#ref, &right.r#ref) + && left_ref == right_ref + && left.validator.is_some() + && left.validator == right.validator + && left != right + { + return Err(ValidationError::new( + ValidationErrorKind::InconsistentObjectObservation { + reference: left_ref.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_path(path: &str) -> Result<(), ValidationError> { + let valid = if path == "/" { + true + } else if let Some(remainder) = path.strip_prefix('/') { + !remainder.is_empty() + && remainder + .split('/') + .all(|segment| !segment.is_empty() && segment != "." && segment != "..") + } else { + false + }; + + if valid { + Ok(()) + } else { + Err(ValidationError::new(ValidationErrorKind::InvalidPath { + path: path.to_owned(), + })) + } +} + 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| { + for declaration in &descriptor.types { + validate_type_expr(&declaration.definition, &declarations).map_err(|error| { error - .prepend(PathSegment::Declaration(declaration.name().to_owned())) - .prepend(PathSegment::Interface(descriptor.name.clone())) + .prepend(PathSegment::Declaration(declaration.name.clone())) + .prepend(PathSegment::Descriptor) })?; } @@ -91,7 +125,7 @@ pub(crate) fn validate_descriptor(descriptor: &InterfaceDescriptor) -> Result<() name: operation.name.clone(), }) .prepend(PathSegment::Operation(operation.name.clone())) - .prepend(PathSegment::Interface(descriptor.name.clone()))); + .prepend(PathSegment::Descriptor)); } let mut parameter_names = HashSet::new(); @@ -103,23 +137,21 @@ pub(crate) fn validate_descriptor(descriptor: &InterfaceDescriptor) -> Result<() }) .prepend(PathSegment::Parameter(parameter.name.clone())) .prepend(PathSegment::Operation(operation.name.clone())) - .prepend(PathSegment::Interface(descriptor.name.clone()))); + .prepend(PathSegment::Descriptor)); } - validate_type_expr(¶meter.ty, &declarations).map_err(|error| { + validate_type_expr(¶meter.r#type, &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())) + .prepend(PathSegment::Descriptor) })?; } + validate_type_expr(&operation.returns.r#type, &declarations).map_err(|error| { + error + .prepend(PathSegment::Return) + .prepend(PathSegment::Operation(operation.name.clone())) + .prepend(PathSegment::Descriptor) + })?; } validate_no_cycles(descriptor, &declarations) @@ -129,30 +161,35 @@ fn declaration_map( descriptor: &InterfaceDescriptor, ) -> Result, ValidationError> { let mut declarations = HashMap::new(); - for declaration in &descriptor.declarations { + for declaration in &descriptor.types { if declarations - .insert(declaration.name(), declaration) + .insert(declaration.name.as_str(), declaration) .is_some() { return Err(ValidationError::new(ValidationErrorKind::DuplicateName { namespace: NameNamespace::TypeDeclaration, - name: declaration.name().to_owned(), + name: declaration.name.clone(), }) - .prepend(PathSegment::Declaration(declaration.name().to_owned())) - .prepend(PathSegment::Interface(descriptor.name.clone()))); + .prepend(PathSegment::Declaration(declaration.name.clone())) + .prepend(PathSegment::Descriptor)); } } Ok(declarations) } -fn validate_declaration( - declaration: &TypeDeclaration, +fn validate_type_expr( + r#type: &TypeExpr, declarations: &HashMap<&str, &TypeDeclaration>, ) -> Result<(), ValidationError> { - match declaration { - TypeDeclaration::Record(record) => { + match r#type { + TypeExpr::Named { name } if !declarations.contains_key(name.as_str()) => { + Err(ValidationError::new(ValidationErrorKind::UnknownType { + name: name.clone(), + })) + } + TypeExpr::Record { fields } => { let mut names = HashSet::new(); - for field in &record.fields { + for field in fields { if !names.insert(field.name.as_str()) { return Err(ValidationError::new(ValidationErrorKind::DuplicateName { namespace: NameNamespace::Field, @@ -160,72 +197,59 @@ fn validate_declaration( }) .prepend(PathSegment::Field(field.name.clone()))); } - validate_type_expr(&field.ty, declarations) + validate_type_expr(&field.r#type, declarations) .map_err(|error| error.prepend(PathSegment::Field(field.name.clone())))?; } + Ok(()) } - 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()))); + TypeExpr::List { items } => validate_type_expr(items, declarations), + TypeExpr::Enum { cases } => validate_case_names( + cases.iter().map(|case| case.name.as_str()), + NameNamespace::EnumCase, + ), + TypeExpr::Union { cases } => { + validate_case_names( + cases.iter().map(|case| case.name.as_str()), + NameNamespace::UnionCase, + )?; + for case in cases { + if let Some(payload) = &case.payload { + validate_type_expr(payload, declarations) + .map_err(|error| error.prepend(PathSegment::Case(case.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(()) } _ => Ok(()), } } +fn validate_case_names<'a>( + names: impl Iterator, + namespace: NameNamespace, +) -> Result<(), ValidationError> { + let mut seen = HashSet::new(); + for name in names { + if !seen.insert(name) { + return Err(ValidationError::new(ValidationErrorKind::DuplicateName { + namespace, + name: name.to_owned(), + }) + .prepend(PathSegment::Case(name.to_owned()))); + } + } + 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 { + for declaration in &descriptor.types { visit_declaration( - declaration.name(), - descriptor, + declaration.name.as_str(), declarations, &mut state, &mut stack, @@ -242,7 +266,6 @@ enum VisitState { 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>, @@ -259,7 +282,7 @@ fn visit_declaration<'a>( return Err( ValidationError::new(ValidationErrorKind::RecursiveType { cycle }) .prepend(PathSegment::Declaration(name.to_owned())) - .prepend(PathSegment::Interface(descriptor.name.clone())), + .prepend(PathSegment::Descriptor), ); } None => {} @@ -268,199 +291,198 @@ fn visit_declaration<'a>( state.insert(name, VisitState::Visiting); stack.push(name); let mut references = Vec::new(); - collect_declaration_references(declarations[name], &mut references); + collect_type_references(&declarations[name].definition, &mut references); for reference in references { - visit_declaration(reference, descriptor, declarations, state, stack)?; + visit_declaration(reference, 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); +fn collect_type_references<'a>(r#type: &'a TypeExpr, references: &mut Vec<&'a str>) { + match r#type { + TypeExpr::Named { name } => references.push(name), + TypeExpr::Record { fields } => { + for field in fields { + collect_type_references(&field.r#type, references); } } - TypeDeclaration::Enum(_) => {} - TypeDeclaration::Union(union) => { - for variant in &union.variants { - collect_type_references(&variant.ty, references); + TypeExpr::List { items } => collect_type_references(items, references), + TypeExpr::Union { cases } => { + for case in cases { + if let Some(payload) = &case.payload { + collect_type_references(payload, 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, + r#type: &TypeExpr, value: &Value, ) -> Result<(), ValidationError> { descriptor.validate()?; let declarations = declaration_map(descriptor)?; - validate_type_expr(ty, &declarations) - .map_err(|error| error.prepend(PathSegment::Interface(descriptor.name.clone())))?; - validate_value(ty, value, &declarations) - .map_err(|error| error.prepend(PathSegment::Interface(descriptor.name.clone()))) + validate_type_expr(r#type, &declarations) + .map_err(|error| error.prepend(PathSegment::Descriptor))?; + validate_value(r#type, value, &declarations) + .map_err(|error| error.prepend(PathSegment::Descriptor)) } fn validate_value( - ty: &TypeExpr, + r#type: &TypeExpr, value: &Value, declarations: &HashMap<&str, &TypeDeclaration>, ) -> Result<(), ValidationError> { - match ty { - TypeExpr::String => expect_kind(value, ValueKind::String), + match r#type { + TypeExpr::Unit => expect_kind(value, ValueKind::Unit), 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::Integer => validate_integer(value), + TypeExpr::Number => validate_number(value), + TypeExpr::String => expect_kind(value, ValueKind::String), 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) + TypeExpr::Json => validate_json(value), + TypeExpr::Entry => match value { + Value::String(path) => validate_path(path), + _ => expect_kind(value, ValueKind::String), + }, + TypeExpr::Named { name } => { + let Some(declaration) = declarations.get(name.as_str()) else { + return Err(ValidationError::new(ValidationErrorKind::UnknownType { + name: name.clone(), + })); + }; + validate_value(&declaration.definition, value, declarations) + } + TypeExpr::Record { fields } => validate_record(fields, value, declarations), + TypeExpr::List { items } => match value { + Value::List(values) => { + for (index, item) in values.iter().enumerate() { + validate_value(items, 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)) + TypeExpr::Enum { cases } => match value { + Value::String(case) if cases.iter().any(|declared| declared.name == *case) => Ok(()), + Value::String(case) => { + Err( + ValidationError::new(ValidationErrorKind::UnknownCase { name: case.clone() }) + .prepend(PathSegment::Case(case.clone())), + ) } - _ => expect_kind(value, ValueKind::Entry), + _ => expect_kind(value, ValueKind::String), }, - TypeExpr::Json => match value { - Value::Json(json) => validate_json(json), - _ => expect_kind(value, ValueKind::Json), - }, - TypeExpr::Named(name) => { - let Some(declaration) = declarations.get(name.as_str()) else { - return Err(ValidationError::new(ValidationErrorKind::UnknownType { - name: name.clone(), - })); - }; - validate_declared_value(declaration, value, declarations) - } + TypeExpr::Union { cases } => validate_union(cases, value, declarations), } } -fn validate_declared_value( - declaration: &TypeDeclaration, +fn validate_integer(value: &Value) -> Result<(), ValidationError> { + 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), + } +} + +fn validate_number(value: &Value) -> Result<(), ValidationError> { + match value { + Value::Number(number) if number.is_finite() => Ok(()), + Value::Number(_) => Err(ValidationError::new(ValidationErrorKind::NonFiniteNumber)), + _ => expect_kind(value, ValueKind::Number), + } +} + +fn validate_record( + fields: &[crate::FieldDeclaration], 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()))); - } - } + let Value::Record(values) = value else { + return expect_kind(value, ValueKind::Record); + }; - 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 (name, field_value) in values { + let Some(field) = fields.iter().find(|field| field.name == *name) else { + return Err(ValidationError::new(ValidationErrorKind::UnknownField { + name: name.clone(), + }) + .prepend(PathSegment::Field(name.clone()))); + }; + validate_value(&field.r#type, field_value, declarations) + .map_err(|error| error.prepend(PathSegment::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), - }, + for field in fields { + if field.required && !values.contains_key(&field.name) { + return Err(ValidationError::new(ValidationErrorKind::MissingField { + name: field.name.clone(), + }) + .prepend(PathSegment::Field(field.name.clone()))); + } + } + Ok(()) +} + +fn validate_union( + cases: &[crate::UnionCase], + value: &Value, + declarations: &HashMap<&str, &TypeDeclaration>, +) -> Result<(), ValidationError> { + let Value::Record(fields) = value else { + return expect_kind(value, ValueKind::Record); + }; + + for name in fields.keys() { + if name != UNION_CASE_FIELD && name != UNION_VALUE_FIELD { + return Err(ValidationError::new(ValidationErrorKind::UnknownField { + name: name.clone(), + }) + .prepend(PathSegment::Field(name.clone()))); + } + } + + let Some(discriminator) = fields.get(UNION_CASE_FIELD) else { + return Err(ValidationError::new(ValidationErrorKind::MissingField { + name: UNION_CASE_FIELD.into(), + }) + .prepend(PathSegment::Field(UNION_CASE_FIELD.into()))); + }; + let Value::String(case_name) = discriminator else { + return expect_kind(discriminator, ValueKind::String) + .map_err(|error| error.prepend(PathSegment::Field(UNION_CASE_FIELD.into()))); + }; + let Some(case) = cases.iter().find(|case| case.name == *case_name) else { + return Err(ValidationError::new(ValidationErrorKind::UnknownCase { + name: case_name.clone(), + }) + .prepend(PathSegment::Case(case_name.clone()))); + }; + + match (&case.payload, fields.get(UNION_VALUE_FIELD)) { + (None, None) => Ok(()), + (None, Some(_)) => Err( + ValidationError::new(ValidationErrorKind::UnexpectedUnionPayload) + .prepend(PathSegment::Field(UNION_VALUE_FIELD.into())), + ), + (Some(_), None) => Err( + ValidationError::new(ValidationErrorKind::MissingUnionPayload) + .prepend(PathSegment::Field(UNION_VALUE_FIELD.into())), + ), + (Some(payload), Some(payload_value)) => { + validate_value(payload, payload_value, declarations) + .map_err(|error| error.prepend(PathSegment::Field(UNION_VALUE_FIELD.into()))) + } } } @@ -475,153 +497,206 @@ fn expect_kind(value: &Value, expected: ValueKind) -> Result<(), ValidationError } } -fn validate_json(value: &JsonValue) -> Result<(), ValidationError> { +fn validate_json(value: &Value) -> Result<(), ValidationError> { match value { - JsonValue::Integer(integer) if !(MIN_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(integer) => { - Err(ValidationError::new( - ValidationErrorKind::IntegerOutOfRange { value: *integer }, - )) + Value::Integer(_) => validate_integer(value), + Value::Number(_) => validate_number(value), + Value::Bytes(_) => Err(ValidationError::new(ValidationErrorKind::BytesInJson)), + Value::Record(fields) => { + for (name, field) in fields { + validate_json(field) + .map_err(|error| error.prepend(PathSegment::Field(name.clone())))?; + } + Ok(()) } - JsonValue::Number(number) if !number.is_finite() => { - Err(ValidationError::new(ValidationErrorKind::NonFiniteNumber)) - } - JsonValue::Array(items) => { + Value::List(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(()), + Value::Unit | Value::Boolean(_) | Value::String(_) => Ok(()), } } -pub(crate) fn validate_call( +pub(crate) fn validate_arguments( descriptor: &InterfaceDescriptor, - call: &OperationCall, + operation_name: &str, + arguments: &BTreeMap, ) -> 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 operation = find_operation(descriptor, operation_name)?; let declarations = declaration_map(descriptor)?; - for argument in &call.arguments { + + for (name, value) in arguments { let Some(parameter) = operation .parameters .iter() - .find(|parameter| parameter.name == argument.name) + .find(|parameter| parameter.name == *name) else { return Err(ValidationError::new(ValidationErrorKind::UnknownParameter { - name: argument.name.clone(), + name: name.clone(), }) - .prepend(PathSegment::Parameter(argument.name.clone())) + .prepend(PathSegment::Parameter(name.clone())) .prepend(PathSegment::Operation(operation.name.clone())) - .prepend(PathSegment::Interface(descriptor.name.clone()))); + .prepend(PathSegment::Descriptor)); }; - validate_value(¶meter.ty, &argument.value, &declarations).map_err(|error| { + validate_value(¶meter.r#type, value, &declarations).map_err(|error| { error - .prepend(PathSegment::Parameter(argument.name.clone())) + .prepend(PathSegment::Parameter(name.clone())) .prepend(PathSegment::Operation(operation.name.clone())) - .prepend(PathSegment::Interface(descriptor.name.clone())) + .prepend(PathSegment::Descriptor) })?; } for parameter in &operation.parameters { - if !parameter.optional && !supplied.contains_key(parameter.name.as_str()) { + if parameter.required && !arguments.contains_key(¶meter.name) { 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()))); + .prepend(PathSegment::Descriptor)); } } Ok(()) } -pub(crate) fn validate_return( +pub(crate) fn validate_result( descriptor: &InterfaceDescriptor, operation_name: &str, - value: Option<&Value>, + result: &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| { + let operation = find_operation(descriptor, operation_name)?; + let declarations = declaration_map(descriptor)?; + validate_value(&operation.returns.r#type, result, &declarations).map_err(|error| { error .prepend(PathSegment::Return) .prepend(PathSegment::Operation(operation.name.clone())) - .prepend(PathSegment::Interface(descriptor.name.clone())) + .prepend(PathSegment::Descriptor) }) } + +pub(crate) fn validate_call( + descriptor: &InterfaceDescriptor, + request: &CallOperationRequest, +) -> Result<(), ValidationError> { + validate_path(&request.target.path) + .map_err(|error| error.prepend(PathSegment::Field("target".into())))?; + validate_arguments(descriptor, &request.operation, &request.arguments) +} + +fn find_operation<'a>( + descriptor: &'a InterfaceDescriptor, + operation_name: &str, +) -> Result<&'a crate::OperationDeclaration, ValidationError> { + descriptor + .operations + .iter() + .find(|operation| operation.name == operation_name) + .ok_or_else(|| { + ValidationError::new(ValidationErrorKind::UnknownOperation { + name: operation_name.to_owned(), + }) + .prepend(PathSegment::Operation(operation_name.to_owned())) + .prepend(PathSegment::Descriptor) + }) +} + +pub(crate) fn validate_fetch_tree_request( + request: &FetchTreeRequest, +) -> Result<(), ValidationError> { + validate_path(&request.path)?; + if request.limit == 0 { + Err(ValidationError::new(ValidationErrorKind::ZeroTreeLimit)) + } else { + Ok(()) + } +} + +pub(crate) fn validate_fetch_tree_response( + response: &FetchTreeResponse, + request: &FetchTreeRequest, +) -> Result<(), ValidationError> { + request.validate()?; + let mut node_count = 0_u32; + validate_tree_node( + &response.root, + &request.path, + 0, + request.depth, + request.limit, + &mut node_count, + ) +} + +fn validate_tree_node( + node: &ObjectTreeNode, + path: &str, + depth: u32, + maximum_depth: u32, + limit: u32, + node_count: &mut u32, +) -> Result<(), ValidationError> { + if depth > maximum_depth { + return Err( + ValidationError::new(ValidationErrorKind::TreeDepthExceeded { + maximum: maximum_depth, + }) + .prepend(PathSegment::Object(path.to_owned())), + ); + } + *node_count = node_count.saturating_add(1); + if *node_count > limit { + return Err( + ValidationError::new(ValidationErrorKind::TreeLimitExceeded { limit }) + .prepend(PathSegment::Object(path.to_owned())), + ); + } + + node.object.validate_at_path(path)?; + let mut names = HashSet::new(); + for child in &node.children { + validate_object_name(&child.object.name, false) + .map_err(|error| error.prepend(PathSegment::Child(child.object.name.clone())))?; + if !names.insert(child.object.name.as_str()) { + return Err(ValidationError::new(ValidationErrorKind::DuplicateName { + namespace: NameNamespace::ChildObject, + name: child.object.name.clone(), + }) + .prepend(PathSegment::Child(child.object.name.clone())) + .prepend(PathSegment::Object(path.to_owned()))); + } + let child_path = if path == "/" { + format!("/{}", child.object.name) + } else { + format!("{path}/{}", child.object.name) + }; + validate_tree_node( + child, + &child_path, + depth.saturating_add(1), + maximum_depth, + limit, + node_count, + )?; + } + Ok(()) +} + +pub(crate) fn validate_fetch_interface_response( + response: &FetchInterfaceResponse, + request: &FetchInterfaceRequest, +) -> Result<(), ValidationError> { + if response.interface != request.interface { + return Err( + ValidationError::new(ValidationErrorKind::InterfaceResponseMismatch { + expected: request.interface.clone(), + actual: response.interface.clone(), + }) + .prepend(PathSegment::Field("interface".into())), + ); + } + response.descriptor.validate() +} diff --git a/crates/wip-protocol/tests/validation.rs b/crates/wip-protocol/tests/validation.rs index 3af79f1..78ee1d0 100644 --- a/crates/wip-protocol/tests/validation.rs +++ b/crates/wip-protocol/tests/validation.rs @@ -1,294 +1,332 @@ +use std::collections::BTreeMap; + 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, + CallOperationRequest, CallOperationResponse, EnumCase, FetchInterfaceRequest, + FetchInterfaceResponse, FetchRequest, FetchTreeRequest, FetchTreeResponse, FieldDeclaration, + INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceTarget, MAX_SAFE_INTEGER, NameNamespace, + Object, ObjectTreeNode, OperationDeclaration, ParameterDeclaration, PathSegment, ProtocolError, + ProtocolErrorCode, ProtocolResult, ReturnDeclaration, Target, TypeDeclaration, TypeExpr, + UnionCase, ValidationErrorKind, Value, ValueKind, }; -fn field(name: &str, ty: TypeExpr, optional: bool) -> FieldDeclaration { +fn record(entries: impl IntoIterator) -> Value { + Value::Record( + entries + .into_iter() + .map(|(name, value)| (name.to_owned(), value)) + .collect(), + ) +} + +fn field(name: &str, r#type: TypeExpr, required: bool) -> FieldDeclaration { FieldDeclaration { name: name.into(), + required, documentation: None, - ty, - optional, + r#type, } } -fn descriptor(name: &str) -> InterfaceDescriptor { +fn descriptor() -> InterfaceDescriptor { InterfaceDescriptor { - name: name.into(), + format: INTERFACE_FORMAT_V1.into(), documentation: None, - declarations: vec![ - TypeDeclaration::Enum(EnumDeclaration { + types: vec![ + TypeDeclaration { 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), + definition: TypeExpr::Enum { + cases: vec![ + EnumCase { + name: "red".into(), + documentation: None, }, - true, - ), - ], - }), - TypeDeclaration::Union(UnionDeclaration { + EnumCase { + name: "blue".into(), + documentation: None, + }, + ], + }, + }, + TypeDeclaration { name: "Selection".into(), documentation: None, - variants: vec![ - UnionVariantDeclaration { - name: "by_name".into(), + definition: TypeExpr::Union { + cases: vec![ + UnionCase { + name: "all".into(), + documentation: None, + payload: None, + }, + UnionCase { + name: "one".into(), + documentation: None, + payload: Some(TypeExpr::Entry), + }, + ], + }, + }, + TypeDeclaration { + name: "Payload".into(), + documentation: None, + definition: TypeExpr::Record { + fields: vec![ + field("title", TypeExpr::String, true), + field( + "color", + TypeExpr::Named { + name: "Color".into(), + }, + false, + ), + field("metadata", TypeExpr::Json, false), + field( + "entries", + TypeExpr::List { + items: Box::new(TypeExpr::Entry), + }, + false, + ), + ], + }, + }, + ], + operations: vec![ + OperationDeclaration { + name: "publish".into(), + documentation: None, + parameters: vec![ + ParameterDeclaration { + name: "payload".into(), + required: true, documentation: None, - ty: TypeExpr::String, + r#type: TypeExpr::Named { + name: "Payload".into(), + }, }, - UnionVariantDeclaration { - name: "by_id".into(), + ParameterDeclaration { + name: "selection".into(), + required: false, documentation: None, - ty: TypeExpr::Integer, + r#type: TypeExpr::Named { + name: "Selection".into(), + }, }, ], - }), - ], - operations: vec![OperationDeclaration { - name: "publish".into(), - documentation: None, - parameters: vec![ - ParameterDeclaration { - name: "payload".into(), + returns: ReturnDeclaration { documentation: None, - ty: TypeExpr::Named("Payload".into()), - optional: false, + r#type: TypeExpr::Boolean, }, - ParameterDeclaration { - name: "selection".into(), - documentation: None, - ty: TypeExpr::Named("Selection".into()), - optional: true, - }, - ], - returns: Some(ReturnDeclaration { + }, + OperationDeclaration { + name: "refresh".into(), documentation: None, - ty: TypeExpr::Boolean, - }), - }], + parameters: vec![], + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::Unit, + }, + }, + ], } } -fn named(name: &str, value: Value) -> NamedValue { - NamedValue { +fn object(name: &str) -> Object { + Object { name: name.into(), - value, + description: Some(format!("Object {name}")), + interfaces: vec!["catalog-v1".into(), "search-v2".into()], + r#ref: Some("opaque-object-ref".into()), + validator: Some(vec![1, 2, 3]), } } #[test] -fn valid_descriptor_values_call_and_return_are_accepted() { - let descriptor = descriptor("catalog"); +fn object_fields_have_canonical_identity_and_ordered_set_semantics() { + let object = object("article"); + object.validate().unwrap(); + object.validate_at_path("/articles/article").unwrap(); + assert_eq!(object.interfaces, ["catalog-v1", "search-v2"]); + + let duplicate = Object { + interfaces: vec!["catalog-v1".into(), "catalog-v1".into()], + ..object.clone() + }; + assert_eq!( + duplicate.validate().unwrap_err().kind, + ValidationErrorKind::DuplicateInterface { + reference: "catalog-v1".into() + } + ); + + for invalid in ["a/b", ".", ".."] { + assert!(matches!( + Object { + name: invalid.into(), + ..object.clone() + } + .validate() + .unwrap_err() + .kind, + ValidationErrorKind::InvalidObjectName { .. } + )); + } + + let root = Object { + name: String::new(), + ..object.clone() + }; + root.validate_at_path("/").unwrap(); + assert!(matches!( + object.validate_at_path("/articles/other").unwrap_err().kind, + ValidationErrorKind::ObjectNameMismatch { .. } + )); +} + +#[test] +fn ref_and_validator_consistency_is_independent_from_path() { + let first = object("article"); + let same = first.clone(); + first.validate_consistency_with(&same).unwrap(); + + let changed_state = Object { + description: Some("changed".into()), + validator: Some(vec![4]), + ..first.clone() + }; + first.validate_consistency_with(&changed_state).unwrap(); + + let inconsistent = Object { + description: Some("different representation".into()), + ..first.clone() + }; + assert_eq!( + first + .validate_consistency_with(&inconsistent) + .unwrap_err() + .kind, + ValidationErrorKind::InconsistentObjectObservation { + reference: "opaque-object-ref".into() + } + ); + + let validator_free = Object { + validator: None, + ..first.clone() + }; + let validator_free_change = Object { + description: Some("latest observation".into()), + validator: None, + ..first.clone() + }; + validator_free + .validate_consistency_with(&validator_free_change) + .unwrap(); + + let unrelated = Object { + r#ref: Some("another-ref".into()), + ..inconsistent + }; + first.validate_consistency_with(&unrelated).unwrap(); +} + +#[test] +fn descriptor_supports_every_canonical_type_expression() { + let descriptor = descriptor(); 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(), + let all = [ + TypeExpr::Unit, + TypeExpr::Boolean, + TypeExpr::Integer, + TypeExpr::Number, + TypeExpr::String, + TypeExpr::Bytes, + TypeExpr::Json, + TypeExpr::Entry, + TypeExpr::Named { + name: "Payload".into(), }, - arguments: vec![named("payload", payload)], - }; - descriptor.validate_call(&call).unwrap(); - descriptor - .validate_return("publish", Some(&Value::Boolean(true))) - .unwrap(); + TypeExpr::Record { + fields: vec![field("ok", TypeExpr::Boolean, true)], + }, + TypeExpr::List { + items: Box::new(TypeExpr::String), + }, + TypeExpr::Enum { + cases: vec![EnumCase { + name: "yes".into(), + documentation: None, + }], + }, + TypeExpr::Union { + cases: vec![UnionCase { + name: "some".into(), + documentation: None, + payload: Some(TypeExpr::Integer), + }], + }, + ]; + assert_eq!(all.len(), 13); } #[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(); +fn descriptor_rejects_duplicate_names_missing_references_and_cycles() { + let mut duplicate = descriptor(); + duplicate.types.push(duplicate.types[0].clone()); assert_eq!( - error.kind, + duplicate.validate().unwrap_err().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!() + let mut missing = descriptor(); + missing.types[2].definition = TypeExpr::List { + items: Box::new(TypeExpr::Named { + name: "Absent".into(), + }), }; - 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(); + let error = missing.validate().unwrap_err(); assert_eq!( error.kind, ValidationErrorKind::UnknownType { - name: "Elsewhere".into() + name: "Absent".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, + PathSegment::Descriptor, + PathSegment::Declaration("Payload".into()) ] ); let cyclic = InterfaceDescriptor { - name: "cyclic".into(), + format: INTERFACE_FORMAT_V1.into(), documentation: None, - declarations: vec![ - TypeDeclaration::Record(RecordDeclaration { + types: vec![ + TypeDeclaration { name: "A".into(), documentation: None, - fields: vec![field("b", TypeExpr::Named("B".into()), false)], - }), - TypeDeclaration::Union(UnionDeclaration { + definition: TypeExpr::Record { + fields: vec![field("b", TypeExpr::Named { name: "B".into() }, true)], + }, + }, + TypeDeclaration { name: "B".into(), documentation: None, - variants: vec![UnionVariantDeclaration { - name: "a".into(), - documentation: None, - ty: TypeExpr::List(Box::new(TypeExpr::Named("A".into()))), - }], - }), + definition: TypeExpr::Union { + cases: vec![UnionCase { + name: "a".into(), + documentation: None, + payload: Some(TypeExpr::List { + items: Box::new(TypeExpr::Named { name: "A".into() }), + }), + }], + }, + }, ], operations: vec![], }; @@ -301,80 +339,86 @@ fn named_references_must_exist_and_must_not_cycle() { } #[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(); +fn inline_record_enum_union_and_operation_names_are_unique() { + let mut duplicate_field = descriptor(); + let TypeExpr::Record { fields } = &mut duplicate_field.types[2].definition else { + unreachable!() + }; + fields.push(field("title", TypeExpr::Integer, false)); assert!(matches!( - error.kind, + duplicate_field.validate().unwrap_err().kind, ValidationErrorKind::DuplicateName { - namespace: NameNamespace::RecordValueField, + namespace: NameNamespace::Field, .. } )); - 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, + let mut duplicate_enum_case = descriptor(); + let TypeExpr::Enum { cases } = &mut duplicate_enum_case.types[0].definition else { + unreachable!() + }; + cases.push(cases[0].clone()); + assert!(matches!( + duplicate_enum_case.validate().unwrap_err().kind, + ValidationErrorKind::DuplicateName { + namespace: NameNamespace::EnumCase, + .. } - ); + )); - // Optional means omission: there is no general-purpose Value::Null. - descriptor - .validate_value( - &ty, - &Value::Record(vec![named("title", Value::String("ok".into()))]), - ) - .unwrap(); + let mut duplicate_operation = descriptor(); + let duplicate = duplicate_operation.operations[0].clone(); + duplicate_operation.operations.push(duplicate); + assert!(matches!( + duplicate_operation.validate().unwrap_err().kind, + ValidationErrorKind::DuplicateName { + namespace: NameNamespace::Operation, + .. + } + )); } #[test] -fn integers_numbers_enums_unions_entries_and_json_are_validated() { - let descriptor = descriptor("catalog"); +fn raw_primitives_records_and_lists_validate_without_field_order_semantics() { + let descriptor = descriptor(); + for (r#type, value) in [ + (TypeExpr::Unit, Value::Unit), + (TypeExpr::Boolean, Value::Boolean(true)), + (TypeExpr::Integer, Value::Integer(MAX_SAFE_INTEGER)), + (TypeExpr::Number, Value::Number(1.5)), + (TypeExpr::String, Value::String("hello".into())), + (TypeExpr::Bytes, Value::Bytes(vec![0, 255])), + ( + TypeExpr::List { + items: Box::new(TypeExpr::Boolean), + }, + Value::List(vec![Value::Boolean(false), Value::Boolean(true)]), + ), + ] { + descriptor.validate_value(&r#type, &value).unwrap(); + } - descriptor - .validate_value(&TypeExpr::Integer, &Value::Integer(MAX_SAFE_INTEGER)) - .unwrap(); + let schema = TypeExpr::Record { + fields: vec![ + field("first", TypeExpr::String, true), + field("second", TypeExpr::Integer, true), + ], + }; + let first_order = record([ + ("first", Value::String("x".into())), + ("second", Value::Integer(2)), + ]); + let reverse_order = Value::Record(BTreeMap::from([ + ("second".into(), Value::Integer(2)), + ("first".into(), Value::String("x".into())), + ])); + descriptor.validate_value(&schema, &first_order).unwrap(); + descriptor.validate_value(&schema, &reverse_order).unwrap(); +} + +#[test] +fn raw_numeric_record_and_list_failures_are_rejected() { + let descriptor = descriptor(); assert_eq!( descriptor .validate_value(&TypeExpr::Integer, &Value::Integer(MAX_SAFE_INTEGER + 1)) @@ -386,178 +430,331 @@ fn integers_numbers_enums_unions_entries_and_json_are_validated() { ); assert_eq!( descriptor - .validate_value(&TypeExpr::Number, &Value::Number(f64::INFINITY)) + .validate_value(&TypeExpr::Number, &Value::Number(f64::NAN)) .unwrap_err() .kind, ValidationErrorKind::NonFiniteNumber ); - - assert!(matches!( + assert_eq!( descriptor .validate_value( - &TypeExpr::Named("Color".into()), - &Value::Enum("green".into()) + &TypeExpr::List { + items: Box::new(TypeExpr::Boolean) + }, + &Value::List(vec![Value::Integer(1)]) ) .unwrap_err() .kind, - ValidationErrorKind::UnknownVariant { .. } + ValidationErrorKind::TypeMismatch { + expected: ValueKind::Boolean, + actual: ValueKind::Integer, + } + ); +} + +#[test] +fn entry_enum_and_union_are_descriptor_interpretations_of_raw_values() { + let descriptor = descriptor(); + descriptor + .validate_value(&TypeExpr::Entry, &Value::String("/articles/one".into())) + .unwrap(); + assert!(matches!( + descriptor + .validate_value(&TypeExpr::Entry, &Value::String("articles//one".into())) + .unwrap_err() + .kind, + ValidationErrorKind::InvalidPath { .. } )); + descriptor .validate_value( - &TypeExpr::Named("Selection".into()), - &Value::Union { - variant: "by_id".into(), - value: Box::new(Value::Integer(7)), + &TypeExpr::Named { + name: "Color".into(), }, + &Value::String("red".into()), ) .unwrap(); assert!(matches!( descriptor .validate_value( - &TypeExpr::Named("Selection".into()), - &Value::Union { - variant: "by_id".into(), - value: Box::new(Value::String("wrong".into())), + &TypeExpr::Named { + name: "Color".into() }, + &Value::String("green".into()) ) .unwrap_err() .kind, - ValidationErrorKind::TypeMismatch { .. } + ValidationErrorKind::UnknownCase { .. } )); - let entry_ty = TypeExpr::Entry { - key: Box::new(TypeExpr::String), - value: Box::new(TypeExpr::Integer), + let selection = TypeExpr::Named { + name: "Selection".into(), }; - let error = descriptor + descriptor .validate_value( - &entry_ty, - &Value::Entry { - key: Box::new(Value::String("key".into())), - value: Box::new(Value::Boolean(false)), - }, + &selection, + &record([("$case", Value::String("all".into()))]), ) - .unwrap_err(); - assert_eq!(error.path.last(), Some(&PathSegment::EntryValue)); + .unwrap(); + descriptor + .validate_value( + &selection, + &record([ + ("$case", Value::String("one".into())), + ("value", Value::String("/items/1".into())), + ]), + ) + .unwrap(); - 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!( + assert_eq!( descriptor - .validate_value(&TypeExpr::Json, &json) + .validate_value( + &selection, + &record([ + ("$case", Value::String("all".into())), + ("value", Value::Unit) + ]) + ) .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() - } + ValidationErrorKind::UnexpectedUnionPayload ); - - 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() - } + descriptor + .validate_value( + &selection, + &record([("$case", Value::String("one".into()))]) + ) + .unwrap_err() + .kind, + ValidationErrorKind::MissingUnionPayload ); } #[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(), +fn json_uses_the_normal_value_tree_and_rejects_bytes() { + let descriptor = descriptor(); + let json = record([ + ("null", Value::Unit), + ("boolean", Value::Boolean(true)), + ("integer", Value::Integer(42)), + ("number", Value::Number(2.5)), + ("string", Value::String("text".into())), + ( + "array", + Value::List(vec![Value::Unit, record([("nested", Value::Unit)])]), + ), + ]); + descriptor.validate_value(&TypeExpr::Json, &json).unwrap(); + + let error = descriptor + .validate_value( + &TypeExpr::Json, + &record([("nested", Value::List(vec![Value::Bytes(vec![1])]))]), + ) + .unwrap_err(); + assert_eq!(error.kind, ValidationErrorKind::BytesInJson); + assert_eq!( + error.path, + vec![ + PathSegment::Descriptor, + PathSegment::Field("nested".into()), + PathSegment::Index(0), + ] + ); +} + +#[test] +fn calls_validate_canonical_paths_named_arguments_and_unit_results() { + let descriptor = descriptor(); + let payload = record([("title", Value::String("hello".into()))]); + let request = CallOperationRequest { + target: Target { + path: "/articles/one".into(), + validator: Some(vec![1]), + }, + interface: InterfaceTarget { + reference: "catalog-v1".into(), + validator: Some(vec![2]), + }, operation: "publish".into(), + arguments: BTreeMap::from([("payload".into(), payload)]), }; + request.validate_with(&descriptor).unwrap(); - let missing = OperationCall { - target: target.clone(), - arguments: vec![], - }; + CallOperationResponse { + result: Value::Boolean(true), + validator: Some(vec![3]), + } + .validate_for(&descriptor, "publish") + .unwrap(); + descriptor.validate_result("refresh", &Value::Unit).unwrap(); + + let mut missing = request.clone(); + missing.arguments.clear(); assert_eq!( - catalog.validate_call(&missing).unwrap_err().kind, + missing.validate_with(&descriptor).unwrap_err().kind, ValidationErrorKind::MissingParameter { name: "payload".into() } ); - let unknown = OperationCall { - target: target.clone(), - arguments: vec![named("other", Value::Boolean(true))], - }; + let mut unknown = request.clone(); + unknown.arguments.insert("extra".into(), Value::Unit); assert_eq!( - catalog.validate_call(&unknown).unwrap_err().kind, + unknown.validate_with(&descriptor).unwrap_err().kind, ValidationErrorKind::UnknownParameter { - name: "other".into() + name: "extra".into() } ); - let duplicate = OperationCall { - target, - arguments: vec![ - named("payload", Value::Record(vec![])), - named("payload", Value::Record(vec![])), - ], + let mut bad_path = request; + bad_path.target.path = "relative".into(); + assert!(matches!( + bad_path.validate_with(&descriptor).unwrap_err().kind, + ValidationErrorKind::InvalidPath { .. } + )); +} + +#[test] +fn fetch_and_fetch_interface_dtos_validate_request_response_correspondence() { + let fetch = FetchRequest { + path: "/articles/one".into(), + }; + fetch.validate().unwrap(); + fetch.validate_response(&object("one")).unwrap(); + assert!(fetch.validate_response(&object("two")).is_err()); + + for invalid in [ + "", + "articles", + "//articles", + "/articles/", + "/./articles", + "/a/../b", + ] { + assert!(matches!( + FetchRequest { + path: invalid.into() + } + .validate() + .unwrap_err() + .kind, + ValidationErrorKind::InvalidPath { .. } + )); + } + + let request = FetchInterfaceRequest { + interface: "catalog-v1".into(), + }; + let response = FetchInterfaceResponse { + interface: "catalog-v1".into(), + descriptor: descriptor(), + validator: Some(vec![9]), + }; + response.validate_for(&request).unwrap(); + assert!(matches!( + FetchInterfaceResponse { + interface: "other".into(), + ..response + } + .validate_for(&request) + .unwrap_err() + .kind, + ValidationErrorKind::InterfaceResponseMismatch { .. } + )); +} + +#[test] +fn fetch_tree_dtos_validate_names_depth_limit_and_siblings() { + let request = FetchTreeRequest { + path: "/articles".into(), + depth: 1, + limit: 3, + }; + let leaf = |name: &str| ObjectTreeNode { + object: object(name), + children: vec![], + }; + let response = FetchTreeResponse { + root: ObjectTreeNode { + object: object("articles"), + children: vec![leaf("one"), leaf("two")], + }, + truncated: false, + }; + response.validate_for(&request).unwrap(); + + let zero = FetchTreeRequest { + limit: 0, + ..request.clone() + }; + assert_eq!( + zero.validate().unwrap_err().kind, + ValidationErrorKind::ZeroTreeLimit + ); + + let shallow = FetchTreeRequest { + depth: 0, + ..request.clone() }; assert!(matches!( - catalog.validate_call(&duplicate).unwrap_err().kind, + response.validate_for(&shallow).unwrap_err().kind, + ValidationErrorKind::TreeDepthExceeded { .. } + )); + + let small = FetchTreeRequest { + limit: 2, + ..request.clone() + }; + assert!(matches!( + response.validate_for(&small).unwrap_err().kind, + ValidationErrorKind::TreeLimitExceeded { .. } + )); + + let duplicate = FetchTreeResponse { + root: ObjectTreeNode { + object: object("articles"), + children: vec![leaf("one"), leaf("one")], + }, + truncated: false, + }; + assert!(matches!( + duplicate.validate_for(&request).unwrap_err().kind, ValidationErrorKind::DuplicateName { - namespace: NameNamespace::Argument, + namespace: NameNamespace::ChildObject, .. } )); +} - assert_eq!( - catalog.validate_return("publish", None).unwrap_err().kind, - ValidationErrorKind::MissingReturnValue - ); +#[test] +fn protocol_result_and_every_canonical_error_code_are_available() { + let codes = [ + ProtocolErrorCode::InvalidRequest, + ProtocolErrorCode::NotFound, + ProtocolErrorCode::InterfaceNotFound, + ProtocolErrorCode::InterfaceMismatch, + ProtocolErrorCode::OperationNotFound, + ProtocolErrorCode::InvalidArguments, + ProtocolErrorCode::InvalidResult, + ProtocolErrorCode::ValidatorRequired, + ProtocolErrorCode::ValidatorMismatch, + ProtocolErrorCode::InterfaceValidatorRequired, + ProtocolErrorCode::InterfaceValidatorMismatch, + ProtocolErrorCode::PermissionDenied, + ProtocolErrorCode::ResourceLimitExceeded, + ProtocolErrorCode::Internal, + ]; + assert_eq!(codes.len(), 14); - let mut no_return = descriptor("catalog"); - no_return.operations[0].returns = None; + let failure: ProtocolResult<()> = ProtocolResult::Failure(ProtocolError { + code: ProtocolErrorCode::InvalidArguments, + message: "payload.title is required".into(), + }); + assert!(matches!(failure, ProtocolResult::Failure(_))); assert_eq!( - no_return - .validate_return("publish", Some(&Value::Boolean(true))) - .unwrap_err() - .kind, - ValidationErrorKind::UnexpectedReturnValue + ProtocolResult::Success(Value::Unit), + ProtocolResult::Success(Value::Unit) ); }