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