fix: align host dispatch with canonical paths

This commit is contained in:
2026-09-29 06:28:33 +09:00
parent 2d524723a4
commit 2efda433e7
6 changed files with 816 additions and 866 deletions
+17 -10
View File
@@ -174,22 +174,29 @@ references are rejected. Registry references are opaque and remain outside the
descriptor; generated descriptors contain neither those references nor Rust descriptor; generated descriptors contain neither those references nor Rust
trait identity. trait identity.
Dispatch is qualified by interface reference and operation name. The host Dispatch is qualified by interface reference and operation name and targets a
checks current object identity and validator policy, current interface canonical Worldspace path. An `ObjectProvider` resolves that path exactly once
membership, interface validator, descriptor operation, arguments, and result inside `with_object`; the host then checks the supplied object validator,
while retaining the selected object and registered artifact. Host-private current interface membership, interface validator, descriptor operation,
fields and backend handles are never projected by `WipObject`. operation-specific object-validator policy, arguments, and authorization in
canonical precedence. Path and children remain provider resolution context and
are never projected by `WipObject`; host-private fields and backend handles are
also excluded.
Application domain failures belong in a derived result union and are returned Application domain failures belong in a derived result union and are returned
through `Ok`. `wip::HostError` is reserved for runtime/backend failures. Invalid through `Ok`. `wip::HostError` is reserved for runtime/backend failures. Invalid
arguments are rejected before invocation; malformed encoded results are arguments are rejected before invocation; malformed encoded results are
rejected as internal host failures and never returned as application data. reported as `InvalidResult` and never returned as application data. Validator,
membership, authorization, and result failures retain their distinct canonical
protocol error codes.
Providers remain responsible for backend atomicity. Enter a transaction, lock, Providers remain responsible for backend atomicity. Enter a transaction, lock,
stable-handle scope, or conditional-update primitive before constructing and stable-handle scope, or conditional-update primitive before invoking the
dispatching the request-scoped `PublishedObject`, and retain it until dispatch `with_object` callback, and retain it until the callback returns. Mutable
returns. The runtime never validates one object and then resolves a second operations must make validator comparison and backend commit atomic and return a
snapshot, but it cannot make an arbitrary external backend atomic by itself. validator for the committed state. The runtime never validates one object and
then resolves a second snapshot, but it cannot make an arbitrary external
backend atomic by itself.
## Validation ## Validation
+219 -156
View File
@@ -3,30 +3,26 @@
//! # Responsibility boundaries //! # Responsibility boundaries
//! //!
//! [`InterfaceRegistry::register`] validates and freezes a generated interface //! [`InterfaceRegistry::register`] validates and freezes a generated interface
//! descriptor, assigns an opaque [`InterfaceRef`], and makes the same descriptor //! descriptor, assigns an opaque [`InterfaceRef`], and leaves that reference
//! observation available to clients and dispatch. [`PublishedObject::expose`] //! outside the descriptor. [`PublishedObject::expose`] explicitly combines a
//! explicitly combines a request-scoped Rust object with a registered interface; //! request-scoped Rust object with a registered interface; its
//! its [`InterfaceImplementation`] bound proves at compile time that the object //! [`InterfaceImplementation`] bound proves at compile time that the object
//! implements that interface. Implementing a Rust trait alone never publishes it, //! implements the interface. Implementing a Rust trait alone never publishes it,
//! and a publication with no interfaces is valid. //! and a publication with no interfaces is valid.
//! //!
//! [`PublishedObject::dispatch`] selects by `(InterfaceRef, operation name)`, //! [`ObjectProvider::call_operation`] resolves `target.path` exactly once and
//! checks object and interface validators, current membership, the frozen //! keeps the resulting object, interface membership, descriptor, implementation,
//! descriptor, and then invokes the matching generated typed dispatcher on the //! and backend target fixed within the provider's [`ObjectProvider::with_object`]
//! same object instance. Arguments and results cross the protocol [`Value`] //! callback. Dispatch is qualified by `(interface reference, operation name)`.
//! boundary only through generated validation and codecs. Rust fields that are //! The same frozen operation declaration validates both arguments and results.
//! not returned by [`WipObject`] are host-private and are never serialized by
//! this crate.
//! //!
//! The call method is deliberately an execution boundary rather than a global //! A provider is responsible for entering a backend transaction, lock,
//! object registry: a provider may resolve and publish an object per request and //! stable-handle scope, or conditional-update primitive before invoking the
//! drop it afterward. The provider must enter any backend-specific transaction, //! callback and retaining it until the callback returns. The runtime does not
//! lock, stable-handle scope, or conditional-update primitive *before* creating //! reopen the path or select another implementation after checking validators,
//! or dispatching the publication and retain it until dispatch returns. This //! but cannot make an arbitrary external backend atomic on its own. Mutable
//! crate keeps validator checks, descriptor and implementation selection, and //! operations must make object-validator comparison and backend commit atomic;
//! invocation in one borrow of that publication; it does not claim that an //! the validator returned after dispatch must describe the committed state.
//! arbitrary backend stays stable, and it never reopens a path or resolves a
//! second implementation snapshot after validation.
#![deny(missing_docs)] #![deny(missing_docs)]
@@ -41,8 +37,13 @@ use std::sync::atomic::{AtomicU64, Ordering};
use wip_interface::DispatchError; use wip_interface::DispatchError;
pub use wip_interface::{InterfaceDefinition, InterfaceImplementation}; pub use wip_interface::{InterfaceDefinition, InterfaceImplementation};
pub use wip_protocol; pub use wip_protocol;
pub use wip_protocol::{
CallOperationRequest as CallRequest, CallOperationResponse as CallResult, InterfaceTarget,
Target,
};
use wip_protocol::{ use wip_protocol::{
InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode, ValidationError, Value, FetchInterfaceResponse, InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode,
ValidationError, Value,
}; };
/// Opaque bytes used as a conditional execution precondition. /// Opaque bytes used as a conditional execution precondition.
@@ -63,14 +64,6 @@ impl Validator {
} }
} }
/// Host-local identity used by the legacy request-scoped publication API.
///
/// Canonical protocol calls target a Worldspace path. This wrapper remains
/// temporarily host-local so T-5 can adapt generated values without assigning
/// object identity semantics to the protocol descriptor model.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ObjectId(pub String);
/// Opaque host-assigned reference to one registered interface artifact. /// Opaque host-assigned reference to one registered interface artifact.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct InterfaceRef(String); pub struct InterfaceRef(String);
@@ -83,22 +76,34 @@ impl InterfaceRef {
} }
} }
/// An immutable observation returned for an opaque interface reference. /// One atomic observation of an interface reference, descriptor, and validator.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct InterfaceObservation { pub struct InterfaceObservation {
/// Opaque reference selected by published objects and operation calls. /// Opaque reference selected by published objects and operation calls.
pub reference: InterfaceRef, pub reference: InterfaceRef,
/// Canonical descriptor frozen when the interface was registered.
pub descriptor: InterfaceDescriptor,
/// Optional validator. Its presence makes it a required call precondition. /// Optional validator. Its presence makes it a required call precondition.
pub validator: Option<Validator>, pub validator: Option<Validator>,
/// Validated descriptor frozen when the interface was registered. }
pub descriptor: InterfaceDescriptor,
impl InterfaceObservation {
/// Converts this host observation to the canonical protocol response.
#[must_use]
pub fn protocol_response(&self) -> FetchInterfaceResponse {
FetchInterfaceResponse {
interface: self.reference.0.clone(),
descriptor: self.descriptor.clone(),
validator: self.validator.as_ref().map(|validator| validator.0.clone()),
}
}
} }
#[derive(Debug)] #[derive(Debug)]
struct InterfaceArtifact { struct InterfaceArtifact {
reference: InterfaceRef, reference: InterfaceRef,
validator: Option<Validator>,
descriptor: InterfaceDescriptor, descriptor: InterfaceDescriptor,
validator: Option<Validator>,
} }
/// A typed registration token for a generated interface definition. /// A typed registration token for a generated interface definition.
@@ -129,12 +134,11 @@ impl<D> RegisteredInterface<D> {
/// Registry of validated, immutable interface artifacts. /// Registry of validated, immutable interface artifacts.
/// ///
/// This registry stores interface definitions, not object instances. Generated /// Generated descriptors are never rewritten to contain their assigned
/// definitions are registered explicitly and receive process-local opaque
/// references. A validator-free artifact is immutable for the lifetime of its /// references. A validator-free artifact is immutable for the lifetime of its
/// reference; a validator-bearing artifact requires that exact validator on a /// reference. A validator-bearing artifact requires that exact validator on a
/// call. Re-registering a definition is rejected instead of silently changing /// call. Re-registering a definition is rejected rather than silently changing
/// the descriptor associated with an existing reference. /// the observation associated with an existing reference.
#[derive(Debug)] #[derive(Debug)]
pub struct InterfaceRegistry { pub struct InterfaceRegistry {
namespace: u64, namespace: u64,
@@ -164,12 +168,6 @@ impl InterfaceRegistry {
} }
/// Registers and freezes one generated interface definition. /// Registers and freezes one generated interface definition.
///
/// `validator` should be present when the host intends the reference to
/// represent a versioned descriptor. Its presence makes the same value a
/// mandatory precondition for dispatch. Registration finalizes the
/// descriptor remains independent of both registration and its assigned
/// opaque reference.
pub fn register<D>( pub fn register<D>(
&mut self, &mut self,
_definition: D, _definition: D,
@@ -198,8 +196,8 @@ impl InterfaceRegistry {
.ok_or(RegistrationError::ReferenceSpaceExhausted)?; .ok_or(RegistrationError::ReferenceSpaceExhausted)?;
let artifact = Arc::new(InterfaceArtifact { let artifact = Arc::new(InterfaceArtifact {
reference: reference.clone(), reference: reference.clone(),
validator,
descriptor, descriptor,
validator,
}); });
self.definitions.insert(definition); self.definitions.insert(definition);
self.artifacts.insert(reference, Arc::clone(&artifact)); self.artifacts.insert(reference, Arc::clone(&artifact));
@@ -211,28 +209,40 @@ impl InterfaceRegistry {
} }
/// Resolves a transport-provided opaque reference string in this registry. /// Resolves a transport-provided opaque reference string in this registry.
///
/// The returned value is registry-scoped and suitable for observation or a
/// qualified [`CallRequest`]. Unknown or foreign references return `None`;
/// constructing arbitrary `InterfaceRef` values is intentionally not part of
/// the public API.
#[must_use] #[must_use]
pub fn resolve(&self, reference: &str) -> Option<InterfaceRef> { pub fn resolve(&self, reference: &str) -> Option<InterfaceRef> {
let reference = InterfaceRef(reference.to_owned()); let reference = InterfaceRef(reference.to_owned());
self.artifacts.contains_key(&reference).then_some(reference) self.artifacts.contains_key(&reference).then_some(reference)
} }
/// Observes the descriptor and optional validator for an opaque reference. /// Observes a registered interface by its host-side reference.
#[must_use] #[must_use]
pub fn observe(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> { pub fn observe(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> {
self.artifacts.get(reference).map(Self::observation)
}
/// Implements canonical `fetch_interface` semantics for an opaque reference.
///
/// Unknown and currently unpublished references map to `InterfaceNotFound`,
/// not object membership errors or authorization failures.
pub fn fetch_interface(&self, reference: &str) -> Result<InterfaceObservation, ProtocolError> {
let reference = InterfaceRef(reference.to_owned());
self.artifacts self.artifacts
.get(reference) .get(&reference)
.map(|artifact| InterfaceObservation { .map(Self::observation)
reference: artifact.reference.clone(), .ok_or_else(|| ProtocolError {
validator: artifact.validator.clone(), code: ProtocolErrorCode::InterfaceNotFound,
descriptor: artifact.descriptor.clone(), message: "interface reference is not currently published".into(),
}) })
} }
fn observation(artifact: &Arc<InterfaceArtifact>) -> InterfaceObservation {
InterfaceObservation {
reference: artifact.reference.clone(),
descriptor: artifact.descriptor.clone(),
validator: artifact.validator.clone(),
}
}
} }
/// Failure while registering a generated interface definition. /// Failure while registering a generated interface definition.
@@ -274,19 +284,32 @@ impl Error for RegistrationError {
/// Public metadata and conditional-execution policy for a host-private object. /// Public metadata and conditional-execution policy for a host-private object.
/// ///
/// Only values returned by this trait are projected into protocol metadata. /// Only values returned by this trait are projected into protocol metadata.
/// Backend handles, filesystem paths, credentials, and other private fields on /// Paths, children, backend handles, credentials, and resolution state are not
/// the implementing type are neither inspected nor serialized. /// part of this API and therefore cannot be serialized into [`Object`].
pub trait WipObject { pub trait WipObject {
/// Returns the current logical identity of this object. /// Returns the public path-segment name of this object.
fn id(&self) -> ObjectId; fn name(&self) -> &str;
/// Returns a short public description, if one is available.
fn description(&self) -> Option<&str> {
None
}
/// Returns optional opaque identity within the client-facing Worldspace.
fn reference(&self) -> Option<&str> {
None
}
/// Returns the current opaque object validator, if any. /// Returns the current opaque object validator, if any.
fn validator(&self) -> Option<&Validator> { fn validator(&self) -> Option<&Validator> {
None None
} }
/// Whether a call must include the current object validator. /// Whether this specific qualified operation requires an object validator.
fn requires_validator(&self) -> bool { ///
/// This hook is evaluated only after membership, interface validators, and
/// operation existence have been checked, preserving canonical precedence.
fn requires_validator(&self, _interface: &InterfaceRef, _operation: &str) -> bool {
false false
} }
} }
@@ -301,7 +324,8 @@ struct BoundInterface<T> {
/// A host-private object instance combined with an explicit ordered interface set. /// A host-private object instance combined with an explicit ordered interface set.
/// ///
/// The instance is owned by this value and can therefore be request-scoped. No /// The instance is owned by this value and can therefore be request-scoped. No
/// global or pre-populated object registry is required. /// global object registry is required. Worldspace paths remain provider context
/// and are deliberately absent from the publication.
pub struct PublishedObject<T: WipObject> { pub struct PublishedObject<T: WipObject> {
implementation: T, implementation: T,
interfaces: Vec<BoundInterface<T>>, interfaces: Vec<BoundInterface<T>>,
@@ -349,63 +373,46 @@ impl<T: WipObject> PublishedObject<T> {
&self.implementation &self.implementation
} }
/// Builds the protocol object metadata with interface insertion order intact. /// Builds canonical protocol object metadata in interface insertion order.
///
/// The observation contains no path, children, descriptor, backend handle,
/// credential, or provider resolution state.
#[must_use] #[must_use]
pub fn protocol_object(&self) -> Object { pub fn protocol_object(&self) -> Object {
let id = self.implementation.id(); Object {
let object = Object { name: self.implementation.name().to_owned(),
name: id.0.clone(), description: self.implementation.description().map(str::to_owned),
description: None,
interfaces: self interfaces: self
.interfaces .interfaces
.iter() .iter()
.map(|bound| bound.artifact.reference.0.clone()) .map(|bound| bound.artifact.reference.0.clone())
.collect(), .collect(),
r#ref: Some(id.0), r#ref: self.implementation.reference().map(str::to_owned),
validator: self validator: self
.implementation .implementation
.validator() .validator()
.map(|validator| validator.0.clone()), .map(|validator| validator.0.clone()),
};
debug_assert!(object.validate().is_ok());
object
} }
/// Dispatches one qualified operation against this current publication.
///
/// Preconditions are checked before generated operation code runs. The
/// selected artifact and function pointer are borrowed once and are not
/// resolved again after validator checks.
pub fn dispatch(&self, request: CallRequest) -> Result<CallResult, CallError> {
if request.object != self.implementation.id() {
return Err(CallError::new(
CallErrorKind::ObjectNotFound,
"call target does not identify this published object",
));
} }
fn dispatch_resolved(
&self,
request: CallRequest,
authorize: impl FnOnce(&T, &InterfaceRef, &str) -> bool,
) -> Result<CallResult, CallError> {
let current_object_validator = self.implementation.validator(); let current_object_validator = self.implementation.validator();
match request.object_validator.as_ref() { if let Some(supplied) = request.target.validator.as_deref()
Some(supplied) if Some(supplied) != current_object_validator => { && current_object_validator.map(Validator::as_bytes) != Some(supplied)
{
return Err(CallError::new( return Err(CallError::new(
CallErrorKind::ValidatorMismatch, CallErrorKind::ValidatorMismatch,
"object validator does not match current object state", "object validator does not match current object state",
)); ));
} }
None if self.implementation.requires_validator() => {
return Err(CallError::new(
CallErrorKind::ValidatorRequired,
"this object requires a validator",
));
}
_ => {}
}
let Some(bound) = self let Some(bound) = self.interfaces.iter().find(|bound| {
.interfaces bound.artifact.reference.as_str() == request.interface.reference.as_str()
.iter() }) else {
.find(|bound| bound.artifact.reference == request.interface.reference)
else {
return Err(CallError::new( return Err(CallError::new(
CallErrorKind::InterfaceMismatch, CallErrorKind::InterfaceMismatch,
"interface is not a member of the current object publication", "interface is not a member of the current object publication",
@@ -414,7 +421,7 @@ impl<T: WipObject> PublishedObject<T> {
match ( match (
bound.artifact.validator.as_ref(), bound.artifact.validator.as_ref(),
request.interface.validator.as_ref(), request.interface.validator.as_deref(),
) { ) {
(Some(_), None) => { (Some(_), None) => {
return Err(CallError::new( return Err(CallError::new(
@@ -422,7 +429,7 @@ impl<T: WipObject> PublishedObject<T> {
"registered interface requires a validator", "registered interface requires a validator",
)); ));
} }
(expected, Some(actual)) if expected != Some(actual) => { (expected, Some(actual)) if expected.map(Validator::as_bytes) != Some(actual) => {
return Err(CallError::new( return Err(CallError::new(
CallErrorKind::InterfaceValidatorMismatch, CallErrorKind::InterfaceValidatorMismatch,
"interface validator does not match the registered descriptor", "interface validator does not match the registered descriptor",
@@ -444,12 +451,34 @@ impl<T: WipObject> PublishedObject<T> {
)); ));
} }
if request.target.validator.is_none()
&& self
.implementation
.requires_validator(&bound.artifact.reference, &request.operation)
{
return Err(CallError::new(
CallErrorKind::ValidatorRequired,
"this operation requires an object validator",
));
}
bound bound
.artifact .artifact
.descriptor .descriptor
.validate_arguments(&request.operation, &request.arguments) .validate_arguments(&request.operation, &request.arguments)
.map_err(|error| CallError::new(CallErrorKind::InvalidArguments, error.to_string()))?; .map_err(|error| CallError::new(CallErrorKind::InvalidArguments, error.to_string()))?;
if !authorize(
&self.implementation,
&bound.artifact.reference,
&request.operation,
) {
return Err(CallError::new(
CallErrorKind::PermissionDenied,
"operation is not permitted for this caller",
));
}
let value = (bound.dispatch)(&self.implementation, &request.operation, request.arguments) let value = (bound.dispatch)(&self.implementation, &request.operation, request.arguments)
.map_err(CallError::from_dispatch)?; .map_err(CallError::from_dispatch)?;
@@ -460,8 +489,11 @@ impl<T: WipObject> PublishedObject<T> {
.map_err(|error| CallError::new(CallErrorKind::InvalidResult, error.to_string()))?; .map_err(|error| CallError::new(CallErrorKind::InvalidResult, error.to_string()))?;
Ok(CallResult { Ok(CallResult {
value, result: value,
validator: self.implementation.validator().cloned(), validator: self
.implementation
.validator()
.map(|validator| validator.0.clone()),
}) })
} }
@@ -472,6 +504,70 @@ impl<T: WipObject> PublishedObject<T> {
} }
} }
/// A request-scoped Worldspace resolver and execution boundary.
///
/// `with_object` must resolve the current object at `path` exactly once and pass
/// a publication that pins its current interface set, implementations, and
/// backend target for the duration of `operation`. Implementations may hold a
/// transaction or lock around the callback. Returning `None` means the path is
/// not published to the caller. The borrowed publication cannot escape the
/// callback, so the default call path cannot outlive that provider boundary.
pub trait ObjectProvider {
/// Host-private object type produced by this provider.
type Object: WipObject;
/// Runs an operation against the single object snapshot resolved at `path`.
fn with_object<R>(
&self,
path: &str,
operation: impl FnOnce(&PublishedObject<Self::Object>) -> R,
) -> Option<R>;
/// Decides whether a declared operation may execute for the current caller.
///
/// The hook runs after all validators, membership, operation lookup, and
/// argument validation, but before implementation code. Returning `false`
/// exposes the operation while producing `PermissionDenied`.
fn authorize(
&self,
_path: &str,
_object: &Self::Object,
_interface: &InterfaceRef,
_operation: &str,
) -> bool {
true
}
/// Fetches the canonical object projection currently published at `path`.
fn fetch(&self, path: &str) -> Result<Object, CallError> {
self.with_object(path, PublishedObject::protocol_object)
.ok_or_else(|| CallError::new(CallErrorKind::NotFound, "target path is not published"))
.and_then(|object| {
object.validate_at_path(path).map_err(|error| {
CallError::new(CallErrorKind::InvalidResult, error.to_string())
})?;
Ok(object)
})
}
/// Resolves and executes a canonical path-targeted operation request.
///
/// Failure checks follow the exact canonical precedence: path, supplied
/// object validator mismatch, membership, interface validator requirement
/// and mismatch, operation existence, object validator requirement,
/// arguments, then authorization. No failed precondition invokes the typed
/// implementation.
fn call_operation(&self, request: CallRequest) -> Result<CallResult, CallError> {
let path = request.target.path.clone();
self.with_object(&path, |publication| {
publication.dispatch_resolved(request, |object, interface, operation| {
self.authorize(&path, object, interface, operation)
})
})
.ok_or_else(|| CallError::new(CallErrorKind::NotFound, "target path is not published"))?
}
}
/// Failure to construct an explicit object publication. /// Failure to construct an explicit object publication.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum PublicationError { pub enum PublicationError {
@@ -493,44 +589,11 @@ impl Display for PublicationError {
impl Error for PublicationError {} impl Error for PublicationError {}
/// Interface portion of a qualified operation target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterfaceTarget {
/// Opaque reference selected from the current object's interface set.
pub reference: InterfaceRef,
/// Validator copied from the descriptor observation, when one was present.
pub validator: Option<Validator>,
}
/// One transport-independent request to a published object operation.
#[derive(Debug, Clone, PartialEq)]
pub struct CallRequest {
/// Current logical object identity.
pub object: ObjectId,
/// Optional current object-state precondition.
pub object_validator: Option<Validator>,
/// Qualified interface reference and descriptor precondition.
pub interface: InterfaceTarget,
/// Operation name within the selected interface descriptor.
pub operation: String,
/// Named protocol arguments.
pub arguments: BTreeMap<String, Value>,
}
/// Successful host dispatch result.
#[derive(Debug, Clone, PartialEq)]
pub struct CallResult {
/// Encoded application result. No-data operations return [`Value::Unit`].
pub value: Value,
/// Object validator observed after the implementation returned.
pub validator: Option<Validator>,
}
/// Stable host dispatch failure kind. /// Stable host dispatch failure kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CallErrorKind { pub enum CallErrorKind {
/// The call names a different or unavailable object. /// The target path is not currently published.
ObjectNotFound, NotFound,
/// The selected interface is not exposed by the current object. /// The selected interface is not exposed by the current object.
InterfaceMismatch, InterfaceMismatch,
/// The operation is absent from the selected descriptor. /// The operation is absent from the selected descriptor.
@@ -539,7 +602,7 @@ pub enum CallErrorKind {
InvalidArguments, InvalidArguments,
/// The encoded implementation result violates its declaration. /// The encoded implementation result violates its declaration.
InvalidResult, InvalidResult,
/// This object requires a validator and the call omitted it. /// This operation requires a validator and the call omitted it.
ValidatorRequired, ValidatorRequired,
/// The supplied object validator is stale or belongs to another object. /// The supplied object validator is stale or belongs to another object.
ValidatorMismatch, ValidatorMismatch,
@@ -547,6 +610,8 @@ pub enum CallErrorKind {
InterfaceValidatorRequired, InterfaceValidatorRequired,
/// The supplied interface validator does not identify the current descriptor. /// The supplied interface validator does not identify the current descriptor.
InterfaceValidatorMismatch, InterfaceValidatorMismatch,
/// The operation is declared but not authorized for the caller.
PermissionDenied,
/// The typed implementation reported a host/backend failure. /// The typed implementation reported a host/backend failure.
Implementation, Implementation,
} }
@@ -602,30 +667,28 @@ impl CallError {
&self.message &self.message
} }
/// Maps this host failure to the existing protocol error taxonomy. /// Maps this host failure to the canonical protocol error taxonomy.
///
/// Validator details remain available through [`CallError::kind`], while
/// the current protocol model represents conditional-request failures as
/// `InvalidRequest`. Result/codec and implementation failures are internal,
/// because exposing malformed host output as an application failure would
/// be unsafe.
#[must_use] #[must_use]
pub fn protocol_error(&self) -> ProtocolError { pub fn protocol_error(&self) -> ProtocolError {
let category = match self.kind { let code = match self.kind {
CallErrorKind::ObjectNotFound => ProtocolErrorCode::NotFound, CallErrorKind::NotFound => ProtocolErrorCode::NotFound,
CallErrorKind::InterfaceMismatch => ProtocolErrorCode::InterfaceMismatch, CallErrorKind::InterfaceMismatch => ProtocolErrorCode::InterfaceMismatch,
CallErrorKind::OperationNotFound => ProtocolErrorCode::OperationNotFound, CallErrorKind::OperationNotFound => ProtocolErrorCode::OperationNotFound,
CallErrorKind::InvalidArguments => ProtocolErrorCode::InvalidArguments, CallErrorKind::InvalidArguments => ProtocolErrorCode::InvalidArguments,
CallErrorKind::ValidatorRequired CallErrorKind::InvalidResult => ProtocolErrorCode::InvalidResult,
| CallErrorKind::ValidatorMismatch CallErrorKind::ValidatorRequired => ProtocolErrorCode::ValidatorRequired,
| CallErrorKind::InterfaceValidatorRequired CallErrorKind::ValidatorMismatch => ProtocolErrorCode::ValidatorMismatch,
| CallErrorKind::InterfaceValidatorMismatch => ProtocolErrorCode::InvalidRequest, CallErrorKind::InterfaceValidatorRequired => {
CallErrorKind::InvalidResult | CallErrorKind::Implementation => { ProtocolErrorCode::InterfaceValidatorRequired
ProtocolErrorCode::Internal
} }
CallErrorKind::InterfaceValidatorMismatch => {
ProtocolErrorCode::InterfaceValidatorMismatch
}
CallErrorKind::PermissionDenied => ProtocolErrorCode::PermissionDenied,
CallErrorKind::Implementation => ProtocolErrorCode::Internal,
}; };
ProtocolError { ProtocolError {
code: category, code,
message: self.message.clone(), message: self.message.clone(),
} }
} }
+439 -314
View File
@@ -1,9 +1,10 @@
use std::cell::{Cell, RefCell}; use std::cell::Cell;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::rc::Rc;
use wip_host::{ use wip_host::{
CallErrorKind, CallRequest, InterfaceRegistry, InterfaceTarget, ObjectId, PublishedObject, CallError, CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget,
Validator, WipObject, ObjectProvider, PublishedObject, RegisteredInterface, Target, Validator, WipObject,
}; };
use wip_interface::{ use wip_interface::{
CodecError, HostError, OperationResult, TypeDeclaration, TypeExpr, Value, WipType, interface, CodecError, HostError, OperationResult, TypeDeclaration, TypeExpr, Value, WipType, interface,
@@ -17,6 +18,9 @@ pub trait Reader {
/// Adds two integers. /// Adds two integers.
fn add(&self, left: i64, right: i64) -> OperationResult<i64>; fn add(&self, left: i64, right: i64) -> OperationResult<i64>;
/// Reports a backend failure.
fn fail(&self) -> OperationResult<String>;
} }
#[interface] #[interface]
@@ -57,31 +61,37 @@ impl WipType for BadValue {
} }
struct Document { struct Document {
id: String, name: String,
description: Option<String>,
reference: Option<String>,
validator: Option<Validator>, validator: Option<Validator>,
require_validator: bool, require_validator: bool,
calls: Cell<usize>, calls: Rc<Cell<usize>>,
private_backend_handle: RefCell<String>, private_backend_handle: String,
} }
impl Document { impl Document {
fn new(id: &str) -> Self { fn open(name: &str, reference: Option<&str>, calls: Rc<Cell<usize>>) -> Self {
Self { Self {
id: id.into(), name: name.into(),
description: Some("public document".into()),
reference: reference.map(str::to_owned),
validator: None, validator: None,
require_validator: false, require_validator: false,
calls: Cell::new(0), calls,
private_backend_handle: RefCell::new("/private/backend/path".into()), private_backend_handle: "/private/backend/path".into(),
} }
} }
fn strict(id: &str, validator: &[u8]) -> Self { fn strict(name: &str, reference: &str, validator: &[u8], calls: Rc<Cell<usize>>) -> Self {
Self { Self {
id: id.into(), name: name.into(),
description: Some("public document".into()),
reference: Some(reference.into()),
validator: Some(Validator::new(validator)), validator: Some(Validator::new(validator)),
require_validator: true, require_validator: true,
calls: Cell::new(0), calls,
private_backend_handle: RefCell::new("secret-handle".into()), private_backend_handle: "secret-handle".into(),
} }
} }
@@ -91,15 +101,23 @@ impl Document {
} }
impl WipObject for Document { impl WipObject for Document {
fn id(&self) -> ObjectId { fn name(&self) -> &str {
ObjectId(self.id.clone()) &self.name
}
fn description(&self) -> Option<&str> {
self.description.as_deref()
}
fn reference(&self) -> Option<&str> {
self.reference.as_deref()
} }
fn validator(&self) -> Option<&Validator> { fn validator(&self) -> Option<&Validator> {
self.validator.as_ref() self.validator.as_ref()
} }
fn requires_validator(&self) -> bool { fn requires_validator(&self, _: &InterfaceRef, _: &str) -> bool {
self.require_validator self.require_validator
} }
} }
@@ -114,6 +132,11 @@ impl Reader for Document {
self.record_call(); self.record_call();
Ok(left + right) Ok(left + right)
} }
fn fail(&self) -> OperationResult<String> {
self.record_call();
Err(HostError::new("backend unavailable"))
}
} }
impl Writer for Document { impl Writer for Document {
@@ -137,366 +160,468 @@ impl Broken for Document {
} }
} }
struct TestProvider {
reader: RegisteredInterface<ReaderDefinition>,
writer: RegisteredInterface<WriterDefinition>,
broken: RegisteredInterface<BrokenDefinition>,
calls: Rc<Cell<usize>>,
resolutions: Cell<usize>,
authorization_checks: Cell<usize>,
deny: Cell<bool>,
switched: Cell<bool>,
}
impl TestProvider {
fn new(
reader: RegisteredInterface<ReaderDefinition>,
writer: RegisteredInterface<WriterDefinition>,
broken: RegisteredInterface<BrokenDefinition>,
) -> Self {
Self {
reader,
writer,
broken,
calls: Rc::new(Cell::new(0)),
resolutions: Cell::new(0),
authorization_checks: Cell::new(0),
deny: Cell::new(false),
switched: Cell::new(false),
}
}
}
impl ObjectProvider for TestProvider {
type Object = Document;
fn with_object<R>(
&self,
path: &str,
operation: impl FnOnce(&PublishedObject<Self::Object>) -> R,
) -> Option<R> {
self.resolutions.set(self.resolutions.get() + 1);
let (document, interfaces) = match path {
"/document" => (
Document::strict(
"document",
"document-ref",
b"object-v1",
Rc::clone(&self.calls),
),
2,
),
"/open" => (
Document::open("open", Some("open-ref"), Rc::clone(&self.calls)),
2,
),
"/left/document" | "/right/document" => (
Document::strict(
"document",
"shared-ref",
b"shared-v1",
Rc::clone(&self.calls),
),
2,
),
"/switch" => {
let switched = self.switched.replace(true);
let (reference, validator) = if switched {
("second-ref", b"second".as_slice())
} else {
("first-ref", b"first".as_slice())
};
(
Document::strict("switch", reference, validator, Rc::clone(&self.calls)),
2,
)
}
"/empty" => (Document::open("empty", None, Rc::clone(&self.calls)), 0),
"/broken" => (
Document::open("broken", Some("broken-ref"), Rc::clone(&self.calls)),
3,
),
_ => return None,
};
let publication = PublishedObject::new(document);
let publication = match interfaces {
0 => publication,
2 => publication
.expose(&self.writer)
.unwrap()
.expose(&self.reader)
.unwrap(),
3 => publication.expose(&self.broken).unwrap(),
_ => unreachable!(),
};
Some(operation(&publication))
}
fn authorize(&self, _: &str, _: &Document, _: &InterfaceRef, _: &str) -> bool {
self.authorization_checks
.set(self.authorization_checks.get() + 1);
!self.deny.get()
}
}
fn registry_and_provider() -> (
InterfaceRegistry,
TestProvider,
RegisteredInterface<HiddenDefinition>,
) {
let mut registry = InterfaceRegistry::new();
let reader = registry
.register(ReaderDefinition, Some(Validator::new(b"schema-v1")))
.unwrap();
let writer = registry.register(WriterDefinition, None).unwrap();
let hidden = registry.register(HiddenDefinition, None).unwrap();
let broken = registry.register(BrokenDefinition, None).unwrap();
let provider = TestProvider::new(reader, writer, broken);
(registry, provider, hidden)
}
fn request( fn request(
object: &str, path: &str,
object_validator: Option<&[u8]>, object_validator: Option<&[u8]>,
reference: &wip_host::InterfaceRef, reference: &InterfaceRef,
interface_validator: Option<&[u8]>, interface_validator: Option<&[u8]>,
operation: &str, operation: &str,
arguments: BTreeMap<String, Value>, arguments: BTreeMap<String, Value>,
) -> CallRequest { ) -> CallRequest {
CallRequest { CallRequest {
object: ObjectId(object.into()), target: Target {
object_validator: object_validator.map(Validator::new), path: path.into(),
validator: object_validator.map(<[u8]>::to_vec),
},
interface: InterfaceTarget { interface: InterfaceTarget {
reference: reference.clone(), reference: reference.as_str().to_owned(),
validator: interface_validator.map(Validator::new), validator: interface_validator.map(<[u8]>::to_vec),
}, },
operation: operation.into(), operation: operation.into(),
arguments, arguments,
} }
} }
#[test] fn assert_failure(error: CallError, kind: CallErrorKind, code: ProtocolErrorCode) {
fn registry_exposes_opaque_validated_descriptor_observations() { assert_eq!(error.kind(), kind);
let mut registry = InterfaceRegistry::new(); assert_eq!(error.protocol_error().code, code);
let reader = registry.register(ReaderDefinition, None).unwrap();
assert_ne!(reader.reference().as_str(), "Reader");
assert_eq!(
registry.resolve(reader.reference().as_str()).as_ref(),
Some(reader.reference())
);
assert!(registry.resolve("wip-interface:unknown:0").is_none());
let observation = registry.observe(reader.reference()).unwrap();
assert_eq!(observation.reference, *reader.reference());
assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
assert_eq!(observation.validator, None);
observation.descriptor.validate().unwrap();
assert!(registry.register(ReaderDefinition, None).is_err());
let mut other_registry = InterfaceRegistry::new();
let other_reader = other_registry.register(ReaderDefinition, None).unwrap();
assert_ne!(reader.reference(), other_reader.reference());
assert!(
registry
.resolve(other_reader.reference().as_str())
.is_none()
);
} }
#[test] #[test]
fn publication_is_explicit_ordered_qualified_and_request_scoped() { fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor() {
let mut registry = InterfaceRegistry::new(); let (registry, provider, _) = registry_and_provider();
let reader = registry.register(ReaderDefinition, None).unwrap(); let reference = provider.reader.reference();
let writer = registry.register(WriterDefinition, None).unwrap();
let _hidden = registry.register(HiddenDefinition, None).unwrap();
let publication = PublishedObject::new(Document::new("document-1")) assert_ne!(reference.as_str(), "Reader");
.expose(&writer) let observation = registry.fetch_interface(reference.as_str()).unwrap();
.unwrap() assert_eq!(observation.reference, *reference);
.expose(&reader) assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
.unwrap(); assert_eq!(observation.validator, Some(Validator::new(b"schema-v1")));
let response = observation.protocol_response();
assert_eq!(response.interface, reference.as_str());
assert_eq!(response.descriptor, ReaderDefinition::descriptor());
assert_eq!(response.validator.as_deref(), Some(b"schema-v1".as_slice()));
response.descriptor.validate().unwrap();
let object = publication.protocol_object(); let error = registry.fetch_interface("unknown").unwrap_err();
assert_eq!(error.code, ProtocolErrorCode::InterfaceNotFound);
}
#[test]
fn canonical_object_projection_supports_optional_metadata_and_ordered_sets() {
let (_, provider, _) = registry_and_provider();
let object = provider.fetch("/document").unwrap();
assert_eq!(object.name, "document");
assert_eq!(object.description.as_deref(), Some("public document"));
assert_eq!(object.r#ref.as_deref(), Some("document-ref"));
assert_eq!(object.validator.as_deref(), Some(b"object-v1".as_slice()));
assert_eq!( assert_eq!(
object.interfaces, object.interfaces,
vec![ vec![
writer.reference().as_str().to_owned(), provider.writer.reference().as_str().to_owned(),
reader.reference().as_str().to_owned() provider.reader.reference().as_str().to_owned(),
] ]
); );
registry assert!(!format!("{object:?}").contains("secret-handle"));
.observe(writer.reference()) assert!(!format!("{object:?}").contains("/document"));
.unwrap()
.descriptor
.validate()
.unwrap();
registry
.observe(reader.reference())
.unwrap()
.descriptor
.validate()
.unwrap();
assert!(!format!("{object:?}").contains("private/backend/path"));
let writer_result = publication let empty = provider.fetch("/empty").unwrap();
.dispatch(request( assert!(empty.interfaces.is_empty());
"document-1", assert_eq!(empty.r#ref, None);
assert_eq!(empty.validator, None);
}
#[test]
fn duplicate_exposure_is_rejected_and_private_state_is_not_projected() {
let (_, provider, _) = registry_and_provider();
let publication = PublishedObject::new(Document::open(
"duplicate",
None, None,
writer.reference(), Rc::clone(&provider.calls),
None,
"label",
BTreeMap::new(),
)) ))
.expose(&provider.reader)
.unwrap(); .unwrap();
let reader_result = publication
.dispatch(request(
"document-1",
None,
reader.reference(),
None,
"label",
BTreeMap::new(),
))
.unwrap();
assert_eq!(writer_result.value, Value::String("writer".into()));
assert_eq!(reader_result.value, Value::String("reader".into()));
assert_eq!(publication.implementation().calls.get(), 2);
assert_eq!( assert_eq!(
publication publication.implementation().private_backend_handle,
.implementation()
.private_backend_handle
.borrow()
.as_str(),
"/private/backend/path" "/private/backend/path"
); );
} let error = match publication.expose(&provider.reader) {
#[test]
fn implemented_but_unexposed_interface_is_not_a_member() {
let mut registry = InterfaceRegistry::new();
let reader = registry.register(ReaderDefinition, None).unwrap();
let hidden = registry.register(HiddenDefinition, None).unwrap();
let publication = PublishedObject::new(Document::new("document-2"))
.expose(&reader)
.unwrap();
let error = publication
.dispatch(request(
"document-2",
None,
hidden.reference(),
None,
"hidden",
BTreeMap::new(),
))
.unwrap_err();
assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch);
assert_eq!(
error.protocol_error().code,
ProtocolErrorCode::InterfaceMismatch
);
assert_eq!(publication.implementation().calls.get(), 0);
}
#[test]
fn zero_interface_objects_are_valid() {
let publication = PublishedObject::new(Document::new("empty"));
let object = publication.protocol_object();
assert_eq!(object.name, "empty");
assert_eq!(object.r#ref.as_deref(), Some("empty"));
assert!(object.interfaces.is_empty());
object.validate().unwrap();
}
#[test]
fn duplicate_exposure_is_rejected_without_reordering() {
let mut registry = InterfaceRegistry::new();
let reader = registry.register(ReaderDefinition, None).unwrap();
let publication = PublishedObject::new(Document::new("duplicate"))
.expose(&reader)
.unwrap();
let error = match publication.expose(&reader) {
Ok(_) => panic!("duplicate exposure unexpectedly succeeded"), Ok(_) => panic!("duplicate exposure unexpectedly succeeded"),
Err(error) => error, Err(error) => error,
}; };
assert!(error.to_string().contains(reader.reference().as_str())); assert!(
} error
.to_string()
#[test] .contains(provider.reader.reference().as_str())
fn operation_and_argument_failures_are_mapped_without_execution() {
let mut registry = InterfaceRegistry::new();
let reader = registry.register(ReaderDefinition, None).unwrap();
let publication = PublishedObject::new(Document::new("validation"))
.expose(&reader)
.unwrap();
let missing = publication
.dispatch(request(
"validation",
None,
reader.reference(),
None,
"missing",
BTreeMap::new(),
))
.unwrap_err();
assert_eq!(missing.kind(), CallErrorKind::OperationNotFound);
assert_eq!(
missing.protocol_error().code,
ProtocolErrorCode::OperationNotFound
); );
let invalid = publication
.dispatch(request(
"validation",
None,
reader.reference(),
None,
"add",
BTreeMap::from([("left".into(), Value::String("wrong".into()))]),
))
.unwrap_err();
assert_eq!(invalid.kind(), CallErrorKind::InvalidArguments);
assert_eq!(
invalid.protocol_error().code,
ProtocolErrorCode::InvalidArguments
);
assert_eq!(publication.implementation().calls.get(), 0);
} }
#[test] #[test]
fn invalid_implementation_result_is_internal_and_not_returned() { fn paths_are_resolution_context_not_object_identity() {
let mut registry = InterfaceRegistry::new(); let (_, provider, _) = registry_and_provider();
let broken = registry.register(BrokenDefinition, None).unwrap();
let publication = PublishedObject::new(Document::new("broken"))
.expose(&broken)
.unwrap();
let error = publication let left = provider.fetch("/left/document").unwrap();
.dispatch(request( let right = provider.fetch("/right/document").unwrap();
"broken", assert_eq!(left, right);
None, assert_eq!(left.r#ref.as_deref(), Some("shared-ref"));
broken.reference(),
None, let first = provider.fetch("/switch").unwrap();
"broken", let second = provider.fetch("/switch").unwrap();
BTreeMap::new(), assert_ne!(first.r#ref, second.r#ref);
)) assert_ne!(first.validator, second.validator);
.unwrap_err();
assert_eq!(error.kind(), CallErrorKind::InvalidResult);
assert_eq!(error.protocol_error().code, ProtocolErrorCode::Internal);
assert_eq!(publication.implementation().calls.get(), 1);
} }
#[test] #[test]
fn object_validator_missing_and_mismatch_are_distinct_and_prevent_execution() { fn path_dispatch_is_interface_qualified_and_resolves_once_per_call() {
let mut registry = InterfaceRegistry::new(); let (_, provider, _) = registry_and_provider();
let reader = registry.register(ReaderDefinition, None).unwrap(); let before = provider.resolutions.get();
let publication = PublishedObject::new(Document::strict("strict", b"object-v1"))
.expose(&reader)
.unwrap();
let missing = publication let writer = provider
.dispatch(request( .call_operation(request(
"strict", "/open",
None, None,
reader.reference(), provider.writer.reference(),
None,
"label",
BTreeMap::new(),
))
.unwrap_err();
assert_eq!(missing.kind(), CallErrorKind::ValidatorRequired);
let mismatch = publication
.dispatch(request(
"strict",
Some(b"stale"),
reader.reference(),
None,
"label",
BTreeMap::new(),
))
.unwrap_err();
assert_eq!(mismatch.kind(), CallErrorKind::ValidatorMismatch);
assert_eq!(publication.implementation().calls.get(), 0);
let result = publication
.dispatch(request(
"strict",
Some(b"object-v1"),
reader.reference(),
None, None,
"label", "label",
BTreeMap::new(), BTreeMap::new(),
)) ))
.unwrap(); .unwrap();
assert_eq!(result.validator, Some(Validator::new(b"object-v1"))); let reader = provider
assert_eq!(publication.implementation().calls.get(), 1); .call_operation(request(
} "/open",
#[test]
fn interface_validator_missing_and_mismatch_are_distinct_and_prevent_execution() {
let mut registry = InterfaceRegistry::new();
let writer = registry
.register(WriterDefinition, Some(Validator::new(b"schema-v1")))
.unwrap();
let publication = PublishedObject::new(Document::new("mutable-interface"))
.expose(&writer)
.unwrap();
let observation = registry.observe(writer.reference()).unwrap();
assert_eq!(observation.validator, Some(Validator::new(b"schema-v1")));
let missing = publication
.dispatch(request(
"mutable-interface",
None, None,
writer.reference(), provider.reader.reference(),
None,
"label",
BTreeMap::new(),
))
.unwrap_err();
assert_eq!(missing.kind(), CallErrorKind::InterfaceValidatorRequired);
let mismatch = publication
.dispatch(request(
"mutable-interface",
None,
writer.reference(),
Some(b"schema-v0"),
"label",
BTreeMap::new(),
))
.unwrap_err();
assert_eq!(mismatch.kind(), CallErrorKind::InterfaceValidatorMismatch);
assert_eq!(publication.implementation().calls.get(), 0);
let result = publication
.dispatch(request(
"mutable-interface",
None,
writer.reference(),
Some(b"schema-v1"), Some(b"schema-v1"),
"label", "label",
BTreeMap::new(), BTreeMap::new(),
)) ))
.unwrap(); .unwrap();
assert_eq!(result.value, Value::String("writer".into()));
let strict = provider
.call_operation(request(
"/document",
Some(b"object-v1"),
provider.reader.reference(),
Some(b"schema-v1"),
"label",
BTreeMap::new(),
))
.unwrap();
assert_eq!(writer.result, Value::String("writer".into()));
assert_eq!(reader.result, Value::String("reader".into()));
assert_eq!(strict.result, Value::String("reader".into()));
assert_eq!(strict.validator.as_deref(), Some(b"object-v1".as_slice()));
assert_eq!(provider.resolutions.get() - before, 3);
assert_eq!(provider.calls.get(), 3);
} }
#[test] #[test]
fn host_failures_map_to_internal_without_becoming_domain_values() { fn call_failures_follow_canonical_precedence_without_execution() {
#[interface] let (_, provider, hidden) = registry_and_provider();
pub trait Failing {
/// Always fails in the host implementation.
fn fail(&self) -> OperationResult<String>;
}
impl Failing for Document { assert_failure(
fn fail(&self) -> OperationResult<String> { provider
Err(HostError::new("backend unavailable")) .call_operation(request(
} "/missing",
} Some(b"stale"),
hidden.reference(),
Some(b"stale"),
"missing",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::NotFound,
ProtocolErrorCode::NotFound,
);
assert_failure(
provider
.call_operation(request(
"/document",
Some(b"stale"),
hidden.reference(),
None,
"missing",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::ValidatorMismatch,
ProtocolErrorCode::ValidatorMismatch,
);
assert_failure(
provider
.call_operation(request(
"/document",
Some(b"object-v1"),
hidden.reference(),
None,
"hidden",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::InterfaceMismatch,
ProtocolErrorCode::InterfaceMismatch,
);
assert_failure(
provider
.call_operation(request(
"/document",
Some(b"object-v1"),
provider.reader.reference(),
None,
"missing",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::InterfaceValidatorRequired,
ProtocolErrorCode::InterfaceValidatorRequired,
);
assert_failure(
provider
.call_operation(request(
"/document",
Some(b"object-v1"),
provider.reader.reference(),
Some(b"stale-schema"),
"missing",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::InterfaceValidatorMismatch,
ProtocolErrorCode::InterfaceValidatorMismatch,
);
assert_failure(
provider
.call_operation(request(
"/document",
None,
provider.reader.reference(),
Some(b"schema-v1"),
"missing",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::OperationNotFound,
ProtocolErrorCode::OperationNotFound,
);
assert_failure(
provider
.call_operation(request(
"/document",
None,
provider.reader.reference(),
Some(b"schema-v1"),
"add",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::ValidatorRequired,
ProtocolErrorCode::ValidatorRequired,
);
assert_failure(
provider
.call_operation(request(
"/document",
Some(b"object-v1"),
provider.reader.reference(),
Some(b"schema-v1"),
"add",
BTreeMap::from([("left".into(), Value::String("wrong".into()))]),
))
.unwrap_err(),
CallErrorKind::InvalidArguments,
ProtocolErrorCode::InvalidArguments,
);
let mut registry = InterfaceRegistry::new(); provider.deny.set(true);
let failing = registry.register(FailingDefinition, None).unwrap(); assert_failure(
let publication = PublishedObject::new(Document::new("failure")) provider
.expose(&failing) .call_operation(request(
.unwrap(); "/document",
let error = publication Some(b"object-v1"),
.dispatch(request( provider.reader.reference(),
"failure", Some(b"schema-v1"),
"label",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::PermissionDenied,
ProtocolErrorCode::PermissionDenied,
);
assert_eq!(provider.authorization_checks.get(), 1);
assert_eq!(provider.calls.get(), 0);
}
#[test]
fn immutable_interface_rejects_a_supplied_validator() {
let (_, provider, _) = registry_and_provider();
assert_failure(
provider
.call_operation(request(
"/open",
None, None,
failing.reference(), provider.writer.reference(),
Some(b"unexpected"),
"label",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::InterfaceValidatorMismatch,
ProtocolErrorCode::InterfaceValidatorMismatch,
);
assert_eq!(provider.calls.get(), 0);
}
#[test]
fn invalid_results_and_backend_failures_keep_distinct_protocol_codes() {
let (_, provider, _) = registry_and_provider();
assert_failure(
provider
.call_operation(request(
"/broken",
None, None,
provider.broken.reference(),
None,
"broken",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::InvalidResult,
ProtocolErrorCode::InvalidResult,
);
assert_failure(
provider
.call_operation(request(
"/open",
None,
provider.reader.reference(),
Some(b"schema-v1"),
"fail", "fail",
BTreeMap::new(), BTreeMap::new(),
)) ))
.unwrap_err(); .unwrap_err(),
assert_eq!(error.kind(), CallErrorKind::Implementation); CallErrorKind::Implementation,
assert_eq!(error.protocol_error().code, ProtocolErrorCode::Internal); ProtocolErrorCode::Internal,
);
} }
@@ -1,4 +1,4 @@
use wip_host::{InterfaceRegistry, ObjectId, PublishedObject, WipObject}; use wip_host::{InterfaceRegistry, PublishedObject, WipObject};
use wip_interface::{OperationResult, interface}; use wip_interface::{OperationResult, interface};
#[interface] #[interface]
@@ -9,8 +9,8 @@ pub trait Search {
struct Object; struct Object;
impl WipObject for Object { impl WipObject for Object {
fn id(&self) -> ObjectId { fn name(&self) -> &str {
ObjectId("object".into()) "object"
} }
} }
+12
View File
@@ -264,3 +264,15 @@ pub struct ProtocolError {
/// Bounded human-readable diagnostic. Consumers must not branch on it. /// Bounded human-readable diagnostic. Consumers must not branch on it.
pub message: String, pub message: String,
} }
impl Display for ProtocolError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
write!(
formatter,
"protocol failure ({:?}): {}",
self.code, self.message
)
}
}
impl Error for ProtocolError {}
+108 -365
View File
@@ -1,24 +1,9 @@
//! End-to-end use of the public WIP protocol, interface, and host APIs. //! End-to-end use of the public WIP protocol, interface, and host APIs.
//! //!
//! This workspace package is deliberately separate from the three library //! This workspace package defines application DTOs and interfaces with
//! crates. It defines application DTOs and interfaces with `wip-interface`, //! `wip-interface`, keeps its implementation private, publishes it through a
//! keeps its implementation private, publishes that implementation with //! request-scoped `wip-host` provider, and crosses the call boundary only with
//! `wip-host`, and crosses the call boundary only with `wip-protocol` values. //! canonical `wip-protocol` values and path targets.
//!
//! The complete scenario can be run as a normal binary or called directly:
//!
//! ```
//! let report = wip_public_api_example::run()?;
//!
//! assert_eq!(report.published_interfaces.len(), 2);
//! assert_eq!(report.catalog_status, "catalog-ready");
//! assert_eq!(report.audit_status, "audit-ready");
//! assert_eq!(
//! report.host_failure,
//! wip_host::CallErrorKind::Implementation,
//! );
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
#![deny(missing_docs)] #![deny(missing_docs)]
@@ -27,14 +12,14 @@ use std::error::Error;
use std::io; use std::io;
use wip_host::{ use wip_host::{
CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget, ObjectId, CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget, ObjectProvider,
PublishedObject, Validator, WipObject, PublishedObject, RegisteredInterface, Target, Validator, WipObject,
}; };
use wip_interface as wip; use wip_interface as wip;
use wip_interface::WipType; use wip_interface::WipType;
use wip_protocol::ProtocolError; use wip_protocol::ProtocolError;
const OBJECT_ID: &str = "article-service"; const OBJECT_PATH: &str = "/article-service";
const OBJECT_VALIDATOR: &[u8] = b"object-v1"; const OBJECT_VALIDATOR: &[u8] = b"object-v1";
const CATALOG_VALIDATOR: &[u8] = b"catalog-v1"; const CATALOG_VALIDATOR: &[u8] = b"catalog-v1";
@@ -48,9 +33,6 @@ pub struct PublishRequest {
} }
/// The application domain result of a publication attempt. /// The application domain result of a publication attempt.
///
/// Rejection is an ordinary typed result. It is intentionally distinct from a
/// host or backend failure, which uses [`wip::HostError`].
#[derive(Debug, Clone, PartialEq, Eq, wip::WipType)] #[derive(Debug, Clone, PartialEq, Eq, wip::WipType)]
pub enum PublishOutcome { pub enum PublishOutcome {
/// Publication succeeded and returned its application identifier. /// Publication succeeded and returned its application identifier.
@@ -73,15 +55,9 @@ pub trait Catalog {
) -> wip::OperationResult<PublishOutcome>; ) -> wip::OperationResult<PublishOutcome>;
/// Returns the catalog-qualified status. /// Returns the catalog-qualified status.
///
/// # Returns
/// A catalog status label.
fn status(&self) -> wip::OperationResult<String>; fn status(&self) -> wip::OperationResult<String>;
/// Demonstrates a host/backend failure rather than a domain result. /// Demonstrates a host/backend failure rather than a domain result.
///
/// # Returns
/// A backend status only when the host can reach it.
fn backend_status(&self) -> wip::OperationResult<String>; fn backend_status(&self) -> wip::OperationResult<String>;
} }
@@ -89,22 +65,13 @@ pub trait Catalog {
#[wip::interface] #[wip::interface]
pub trait Audit { pub trait Audit {
/// Returns the audit-qualified status. /// Returns the audit-qualified status.
///
/// This deliberately has the same operation name as [`Catalog::status`].
///
/// # Returns
/// An audit status label.
fn status(&self) -> wip::OperationResult<String>; fn status(&self) -> wip::OperationResult<String>;
} }
/// A registered but deliberately unexposed interface used to demonstrate that /// A registered but deliberately unexposed interface.
/// Rust trait implementation does not imply protocol membership.
#[wip::interface] #[wip::interface]
pub trait Diagnostics { pub trait Diagnostics {
/// Returns host-private diagnostics when the interface is explicitly exposed. /// Returns host-private diagnostics when explicitly exposed.
///
/// # Returns
/// A diagnostic label.
fn diagnostics(&self) -> wip::OperationResult<String>; fn diagnostics(&self) -> wip::OperationResult<String>;
} }
@@ -133,106 +100,76 @@ pub struct DemoReport {
/// Runs the complete end-to-end example. /// Runs the complete end-to-end example.
/// ///
/// The function registers and observes generated descriptors, explicitly /// The provider resolves the current object from a Worldspace path for each
/// exposes two interfaces on one host-private object, validates the resulting /// call. Interface observations remain separate from object projection, and all
/// protocol observation, dispatches interface-qualified calls using named /// calls carry independent object and interface validators.
/// [`wip_protocol::Value`] arguments, decodes validated values back to
/// application types, and keeps a domain rejection distinct from a host
/// failure.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if registration, publication, observation validation, /// Returns an error if registration, observation, path resolution, dispatch, or
/// dispatch, or application-value decoding fails unexpectedly. /// application-value decoding fails unexpectedly.
pub fn run() -> Result<DemoReport, Box<dyn Error>> { pub fn run() -> Result<DemoReport, Box<dyn Error>> {
let mut registry = InterfaceRegistry::new(); let mut registry = InterfaceRegistry::new();
let catalog = registry.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))?; let catalog = registry.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))?;
let audit = registry.register(AuditDefinition, None)?; let audit = registry.register(AuditDefinition, None)?;
let _diagnostics = registry.register(DiagnosticsDefinition, None)?; let _diagnostics = registry.register(DiagnosticsDefinition, None)?;
let provider = ApplicationProvider {
catalog: catalog.clone(),
audit: audit.clone(),
};
let publication = PublishedObject::new(ApplicationService::new()) let object = provider.fetch(OBJECT_PATH)?;
.expose(&catalog)? let catalog_observation = registry.fetch_interface(
.expose(&audit)?; object
let object = publication.protocol_object();
// Model a transport/provider that retained only the protocol object's
// opaque strings, then resolved them back through the registry's public API.
let catalog_wire_reference = object
.interfaces .interfaces
.first() .first()
.ok_or_else(|| io::Error::other("catalog interface was not published"))?; .ok_or_else(|| io::Error::other("catalog interface was not published"))?,
let audit_wire_reference = object )?;
let audit_observation = registry.fetch_interface(
object
.interfaces .interfaces
.get(1) .get(1)
.ok_or_else(|| io::Error::other("audit interface was not published"))?; .ok_or_else(|| io::Error::other("audit interface was not published"))?,
let catalog_reference = registry )?;
.resolve(catalog_wire_reference)
.ok_or_else(|| io::Error::other("published catalog reference did not resolve"))?;
let audit_reference = registry
.resolve(audit_wire_reference)
.ok_or_else(|| io::Error::other("published audit reference did not resolve"))?;
let catalog_observation = registry
.observe(&catalog_reference)
.ok_or_else(|| io::Error::other("registered catalog descriptor was not observable"))?;
let audit_observation = registry
.observe(&audit_reference)
.ok_or_else(|| io::Error::other("registered audit descriptor was not observable"))?;
catalog_observation.descriptor.validate()?;
audit_observation.descriptor.validate()?;
let published = dispatch_publish( let published = dispatch_publish(
&publication, &provider,
&catalog_observation.reference, catalog.reference(),
PublishRequest { PublishRequest {
title: "Public APIs".into(), title: "Public APIs".into(),
body: Some("protocol values in, validated values out".into()), body: Some("protocol values in, validated values out".into()),
}, },
)?; )?;
let rejected = dispatch_publish( let rejected = dispatch_publish(
&publication, &provider,
&catalog_observation.reference, catalog.reference(),
PublishRequest { PublishRequest {
title: String::new(), title: String::new(),
body: None, body: None,
}, },
)?; )?;
let catalog_status = dispatch_string( let catalog_status = dispatch_string(
&publication, &provider,
request( request(
catalog_observation.reference.clone(), catalog.reference(),
Some(Validator::new(CATALOG_VALIDATOR)), Some(CATALOG_VALIDATOR),
"status", "status",
BTreeMap::new(), BTreeMap::new(),
), ),
"catalog status",
)?; )?;
let audit_status = dispatch_string( let audit_status = dispatch_string(
&publication, &provider,
request( request(audit.reference(), None, "status", BTreeMap::new()),
audit_observation.reference.clone(),
None,
"status",
BTreeMap::new(),
),
"audit status",
)?; )?;
let host_error = match publication.dispatch(request( let host_error = provider
catalog_observation.reference.clone(), .call_operation(request(
Some(Validator::new(CATALOG_VALIDATOR)), catalog.reference(),
Some(CATALOG_VALIDATOR),
"backend_status", "backend_status",
BTreeMap::new(), BTreeMap::new(),
)) { ))
Ok(_) => { .unwrap_err();
return Err(io::Error::other(
"the deliberately unavailable example backend unexpectedly succeeded",
)
.into());
}
Err(error) => error,
};
if host_error.kind() != CallErrorKind::Implementation { if host_error.kind() != CallErrorKind::Implementation {
return Err(io::Error::other(format!( return Err(io::Error::other(format!(
"unexpected host failure kind: {:?}", "unexpected host failure kind: {:?}",
@@ -256,49 +193,73 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
} }
fn dispatch_publish( fn dispatch_publish(
publication: &PublishedObject<ApplicationService>, provider: &ApplicationProvider,
catalog: &InterfaceRef, catalog: &InterfaceRef,
request_value: PublishRequest, request_value: PublishRequest,
) -> Result<PublishOutcome, Box<dyn Error>> { ) -> Result<PublishOutcome, Box<dyn Error>> {
let encoded_request = request_value.encode()?; let encoded_request = request_value.encode()?;
let result = publication.dispatch(request( let result = provider.call_operation(request(
catalog.clone(), catalog,
Some(Validator::new(CATALOG_VALIDATOR)), Some(CATALOG_VALIDATOR),
"publish", "publish",
BTreeMap::from([("request".into(), encoded_request)]), BTreeMap::from([("request".into(), encoded_request)]),
))?; ))?;
let value = result.value; Ok(PublishOutcome::decode(result.result)?)
Ok(PublishOutcome::decode(value)?)
} }
fn dispatch_string( fn dispatch_string(
publication: &PublishedObject<ApplicationService>, provider: &ApplicationProvider,
request: CallRequest, request: CallRequest,
_context: &str,
) -> Result<String, Box<dyn Error>> { ) -> Result<String, Box<dyn Error>> {
let result = publication.dispatch(request)?; let result = provider.call_operation(request)?;
let value = result.value; Ok(String::decode(result.result)?)
Ok(String::decode(value)?)
} }
fn request( fn request(
interface: InterfaceRef, interface: &InterfaceRef,
interface_validator: Option<Validator>, interface_validator: Option<&[u8]>,
operation: &str, operation: &str,
arguments: BTreeMap<String, wip_protocol::Value>, arguments: BTreeMap<String, wip_protocol::Value>,
) -> CallRequest { ) -> CallRequest {
CallRequest { CallRequest {
object: ObjectId(OBJECT_ID.into()), target: Target {
object_validator: Some(Validator::new(OBJECT_VALIDATOR)), path: OBJECT_PATH.into(),
validator: Some(OBJECT_VALIDATOR.to_vec()),
},
interface: InterfaceTarget { interface: InterfaceTarget {
reference: interface, reference: interface.as_str().to_owned(),
validator: interface_validator, validator: interface_validator.map(<[u8]>::to_vec),
}, },
operation: operation.into(), operation: operation.into(),
arguments, arguments,
} }
} }
struct ApplicationProvider {
catalog: RegisteredInterface<CatalogDefinition>,
audit: RegisteredInterface<AuditDefinition>,
}
impl ObjectProvider for ApplicationProvider {
type Object = ApplicationService;
fn with_object<R>(
&self,
path: &str,
operation: impl FnOnce(&PublishedObject<Self::Object>) -> R,
) -> Option<R> {
if path != OBJECT_PATH {
return None;
}
let publication = PublishedObject::new(ApplicationService::new())
.expose(&self.catalog)
.expect("ApplicationService implements Catalog")
.expose(&self.audit)
.expect("ApplicationService implements Audit");
Some(operation(&publication))
}
}
struct ApplicationService { struct ApplicationService {
object_validator: Validator, object_validator: Validator,
backend_connection: String, backend_connection: String,
@@ -314,15 +275,23 @@ impl ApplicationService {
} }
impl WipObject for ApplicationService { impl WipObject for ApplicationService {
fn id(&self) -> ObjectId { fn name(&self) -> &str {
ObjectId(OBJECT_ID.into()) "article-service"
}
fn description(&self) -> Option<&str> {
Some("Article publication service")
}
fn reference(&self) -> Option<&str> {
Some("article-service-instance")
} }
fn validator(&self) -> Option<&Validator> { fn validator(&self) -> Option<&Validator> {
Some(&self.object_validator) Some(&self.object_validator)
} }
fn requires_validator(&self) -> bool { fn requires_validator(&self, _: &InterfaceRef, _: &str) -> bool {
true true
} }
} }
@@ -364,254 +333,28 @@ impl Diagnostics for ApplicationService {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use wip_host::CallError; use wip_protocol::ProtocolErrorCode;
use wip_interface::{CodecError, TypeDeclaration, TypeExpr};
use wip_protocol::{ProtocolErrorCode, Value};
/// A deliberately inconsistent custom codec used to prove result validation.
pub struct InvalidValue;
impl WipType for InvalidValue {
fn type_expr() -> TypeExpr {
TypeExpr::String
}
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> {
Ok(Value::Integer(7))
}
fn decode(_: Value) -> Result<Self, CodecError> {
Ok(Self)
}
}
/// Test-only interface whose custom codec emits an invalid result.
#[wip::interface]
pub trait BrokenResult {
/// Returns a value that violates its declared protocol type.
fn broken_result(&self) -> wip::OperationResult<InvalidValue>;
}
impl BrokenResult for ApplicationService {
fn broken_result(&self) -> wip::OperationResult<InvalidValue> {
Ok(InvalidValue)
}
}
struct Fixture {
publication: PublishedObject<ApplicationService>,
catalog: InterfaceRef,
audit: InterfaceRef,
diagnostics: InterfaceRef,
}
fn fixture() -> Fixture {
let mut registry = InterfaceRegistry::new();
let catalog = registry
.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))
.unwrap();
let audit = registry.register(AuditDefinition, None).unwrap();
let diagnostics = registry.register(DiagnosticsDefinition, None).unwrap();
let publication = PublishedObject::new(ApplicationService::new())
.expose(&catalog)
.unwrap()
.expose(&audit)
.unwrap();
Fixture {
publication,
catalog: catalog.reference().clone(),
audit: audit.reference().clone(),
diagnostics: diagnostics.reference().clone(),
}
}
fn assert_failure(error: CallError, kind: CallErrorKind, code: ProtocolErrorCode) {
assert_eq!(error.kind(), kind);
assert_eq!(error.protocol_error().code, code);
}
#[test] #[test]
fn interface_membership_and_same_name_operations_are_qualified() { fn unexposed_interfaces_are_membership_failures() {
let fixture = fixture(); let mut registry = InterfaceRegistry::new();
let catalog = registry.register(CatalogDefinition, None).unwrap();
let audit = registry.register(AuditDefinition, None).unwrap();
let diagnostics = registry.register(DiagnosticsDefinition, None).unwrap();
let provider = ApplicationProvider { catalog, audit };
let catalog = dispatch_string( let error = provider
&fixture.publication, .call_operation(request(
request( diagnostics.reference(),
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
BTreeMap::new(),
),
"catalog status",
)
.unwrap();
let audit = dispatch_string(
&fixture.publication,
request(fixture.audit.clone(), None, "status", BTreeMap::new()),
"audit status",
)
.unwrap();
assert_eq!(catalog, "catalog-ready");
assert_eq!(audit, "audit-ready");
let not_exposed = fixture
.publication
.dispatch(request(
fixture.diagnostics,
None, None,
"diagnostics", "diagnostics",
BTreeMap::new(), BTreeMap::new(),
)) ))
.unwrap_err(); .unwrap_err();
assert_failure( assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch);
not_exposed, assert_eq!(
CallErrorKind::InterfaceMismatch, error.protocol_error().code,
ProtocolErrorCode::InterfaceMismatch, ProtocolErrorCode::InterfaceMismatch
);
}
#[test]
fn every_host_precondition_and_dispatch_failure_is_typed() {
let fixture = fixture();
let mut wrong_object = request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
BTreeMap::new(),
);
wrong_object.object = ObjectId("other-object".into());
assert_failure(
fixture.publication.dispatch(wrong_object).unwrap_err(),
CallErrorKind::ObjectNotFound,
ProtocolErrorCode::NotFound,
);
let mut missing_object_validator = request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
BTreeMap::new(),
);
missing_object_validator.object_validator = None;
assert_failure(
fixture
.publication
.dispatch(missing_object_validator)
.unwrap_err(),
CallErrorKind::ValidatorRequired,
ProtocolErrorCode::InvalidRequest,
);
let mut stale_object_validator = request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
BTreeMap::new(),
);
stale_object_validator.object_validator = Some(Validator::new(b"stale-object"));
assert_failure(
fixture
.publication
.dispatch(stale_object_validator)
.unwrap_err(),
CallErrorKind::ValidatorMismatch,
ProtocolErrorCode::InvalidRequest,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
None,
"status",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::InterfaceValidatorRequired,
ProtocolErrorCode::InvalidRequest,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
Some(Validator::new(b"stale-interface")),
"status",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::InterfaceValidatorMismatch,
ProtocolErrorCode::InvalidRequest,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"missing",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::OperationNotFound,
ProtocolErrorCode::OperationNotFound,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"publish",
BTreeMap::from([("request".into(), Value::String("not a record".into()))]),
))
.unwrap_err(),
CallErrorKind::InvalidArguments,
ProtocolErrorCode::InvalidArguments,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog,
Some(Validator::new(CATALOG_VALIDATOR)),
"backend_status",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::Implementation,
ProtocolErrorCode::Internal,
);
}
#[test]
fn invalid_implementation_result_never_crosses_the_host_boundary() {
let mut registry = InterfaceRegistry::new();
let broken = registry.register(BrokenResultDefinition, None).unwrap();
let publication = PublishedObject::new(ApplicationService::new())
.expose(&broken)
.unwrap();
let error = publication
.dispatch(request(
broken.reference().clone(),
None,
"broken_result",
BTreeMap::new(),
))
.unwrap_err();
assert_failure(
error,
CallErrorKind::InvalidResult,
ProtocolErrorCode::Internal,
); );
} }
} }