Compare commits

..
19 changed files with 2981 additions and 2403 deletions
+46 -22
View File
@@ -30,8 +30,9 @@ wip-public-api-example (workspace-only executable, integration tests, doctest)
The boundary is intentional: protocol types describe what can cross a The boundary is intentional: protocol types describe what can cross a
transport; application DTOs opt into that model with `WipType`; a host-private transport; application DTOs opt into that model with `WipType`; a host-private
implementation owns credentials, handles, and backend state; and a future implementation owns credentials, handles, and backend state; and a future
transport maps its own routes/envelopes to `OperationCall`/`OperationResult` transport maps its own routes/envelopes to `CallOperationRequest` and
without placing transport-specific types in descriptors or application traits. `CallOperationResponse` without placing transport-specific types in descriptors
or application traits.
## End-to-end example ## End-to-end example
@@ -104,12 +105,13 @@ A supported interface is a public, non-generic trait containing methods with:
- `()` only as the application return when no value is produced; and - `()` only as the application return when no value is produced; and
- direct `Option<T>` only for an omittable parameter. - direct `Option<T>` only for an omittable parameter.
`WipType` derives support named-field structs, all-unit enums, and enums whose `WipType` derives support named-field structs, all-unit enums, and unions whose
variants each contain exactly one unnamed payload. The macros reject private, cases may be unit variants or variants containing exactly one unnamed payload.
The macros reject private,
generic, inherited, unsafe, auto, or associated-item traits; const, async, generic, inherited, unsafe, auto, or associated-item traits; const, async,
unsafe, extern, variadic, generic, default-bodied, or non-`&self` methods; unsafe, extern, variadic, generic, default-bodied, or non-`&self` methods;
borrowed/custom-lifetime values; tuple/unit/generic structs; Rust unions; borrowed/custom-lifetime values; tuple/unit/generic structs; Rust unions;
mixed/named-field enum variants; explicit discriminants; nested `Option`; and named-field/multi-field enum variants; explicit discriminants; nested `Option`; and
`Option` in returns, collections, entries, or union payloads. These diagnostics `Option` in returns, collections, entries, or union payloads. These diagnostics
are compile-fail tested under `crates/wip-interface/tests/ui`. are compile-fail tested under `crates/wip-interface/tests/ui`.
@@ -117,23 +119,36 @@ are compile-fail tested under `crates/wip-interface/tests/ui`.
| Rust | Protocol | | Rust | Protocol |
| --- | --- | | --- | --- |
| `()` as an operation result | no returned value | | `()` | unit / `Value::Unit` |
| `bool` | boolean | | `bool` | boolean |
| integer primitives | JavaScript-lossless integer | | integer primitives | JavaScript-lossless integer |
| `f32`, `f64` | finite number | | `f32`, `f64` | finite number |
| `String` | string | | `String` | string |
| `wip::Bytes` | bytes | | `wip::Bytes` | bytes |
| `wip::JsonValue` | JSON | | `wip::JsonValue` | JSON interpreted over the schema-neutral `Value` tree |
| `wip::Entry<K, V>` | entry | | `wip::Entry` | canonical absolute Worldspace path encoded as a string |
| `Vec<T>` | list | | `Vec<T>` | list |
| named-field `#[derive(wip::WipType)]` struct | named record | | named-field `#[derive(wip::WipType)]` struct | named record |
| all-unit `#[derive(wip::WipType)]` enum | named enum | | all-unit `#[derive(wip::WipType)]` enum | named enum encoded as a string |
| all-newtype `#[derive(wip::WipType)]` enum | named tagged union | | mixed unit/newtype or all-newtype enum | named union encoded as a discriminator record |
`Option<T>` means that a direct DTO field or operation parameter may be `Option<T>` means that a direct DTO field or operation parameter may be
omitted; it is not protocol null. JSON null remains available inside omitted; it is not protocol null. JSON null remains available inside
`wip::JsonValue`. `wip::JsonValue`.
#### Migration from the pre-canonical value API
The old generic `wip::Entry<K, V>` represented an application key/value pair
and is no longer available. Use `wip::Entry::new("/canonical/path")` for a
Worldspace entry path and place any associated application data in a record.
`Entry` encodes as `Value::String` and validates the canonical absolute path.
The semantic `Value::Json`, `Value::Entry`, `Value::Enum`, and `Value::Union`
variants have also been removed. JSON recursively uses `Unit`, primitive,
`List`, and `Record` values and rejects `Bytes`; enums use `String`; unions use
a `Record` containing `$case` and, for payload-bearing cases, `value`. Unit
operation results are represented explicitly as `Value::Unit`.
Rust doc comments become descriptor metadata. For an operation, a `# Returns` Rust doc comments become descriptor metadata. For an operation, a `# Returns`
section documents the return declaration; text before that section documents section documents the return declaration; text before that section documents
the operation itself. the operation itself.
@@ -155,25 +170,34 @@ let protocol_object = published.protocol_object();
`expose` compiles only when the object implements the generated definition's `expose` compiles only when the object implements the generated definition's
interface trait. Implementing a trait does not expose it automatically, empty interface trait. Implementing a trait does not expose it automatically, empty
interface sets are valid, insertion order is preserved, and duplicate interface sets are valid, insertion order is preserved, and duplicate
references are rejected. Registry references are opaque; descriptor names and references are rejected. Registry references are opaque and remain outside the
Rust type names are not used as independent wire identity. descriptor; generated descriptors contain neither those references nor Rust
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` validates that path before
membership, interface validator, descriptor operation, arguments, and result resolution, maps malformed paths to `InvalidRequest`, then resolves a valid path
while retaining the selected object and registered artifact. Host-private exactly once inside `with_object`; the host checks the supplied object validator,
fields and backend handles are never projected by `WipObject`. current interface membership, interface validator, descriptor operation,
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
+239 -166
View File
@@ -3,35 +3,31 @@
//! # 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)]
use std::any::TypeId; use std::any::TypeId;
use std::collections::{HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::error::Error; use std::error::Error;
use std::fmt::{self, Display, Formatter}; use std::fmt::{self, Display, Formatter};
use std::marker::PhantomData; use std::marker::PhantomData;
@@ -41,9 +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, NamedValue, Object, ObjectId, OperationCall, OperationTarget, FetchInterfaceResponse, InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode,
ProtocolError, ProtocolErrorCategory, ValidationError, Value, ValidationError, Value,
}; };
/// Opaque bytes used as a conditional execution precondition. /// Opaque bytes used as a conditional execution precondition.
@@ -76,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.
@@ -122,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,
@@ -157,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's protocol `name` to the assigned opaque reference so it
/// corresponds directly to [`Object::interfaces`].
pub fn register<D>( pub fn register<D>(
&mut self, &mut self,
_definition: D, _definition: D,
@@ -176,7 +181,7 @@ impl InterfaceRegistry {
return Err(RegistrationError::AlreadyRegistered); return Err(RegistrationError::AlreadyRegistered);
} }
let mut descriptor = D::descriptor(); let descriptor = D::descriptor();
descriptor descriptor
.validate() .validate()
.map_err(RegistrationError::InvalidDescriptor)?; .map_err(RegistrationError::InvalidDescriptor)?;
@@ -185,19 +190,14 @@ impl InterfaceRegistry {
"wip-interface:{}:{}", "wip-interface:{}:{}",
self.namespace, self.next_reference self.namespace, self.next_reference
)); ));
// The protocol model currently carries the interface identifier in the
// descriptor's `name` field. Finalize that identity to the same opaque
// reference placed on published objects; the generated Rust trait name
// is registration-local metadata, not wire identity.
descriptor.name = reference.0.clone();
self.next_reference = self self.next_reference = self
.next_reference .next_reference
.checked_add(1) .checked_add(1)
.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));
@@ -209,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.
@@ -272,24 +284,37 @@ 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
} }
} }
type DispatchFn<T> = fn(&T, &str, Vec<NamedValue>) -> Result<Option<Value>, DispatchError>; type DispatchFn<T> = fn(&T, &str, BTreeMap<String, Value>) -> Result<Value, DispatchError>;
struct BoundInterface<T> { struct BoundInterface<T> {
artifact: Arc<InterfaceArtifact>, artifact: Arc<InterfaceArtifact>,
@@ -299,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>>,
@@ -347,56 +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 object = Object { Object {
id: self.implementation.id(), name: self.implementation.name().to_owned(),
description: self.implementation.description().map(str::to_owned),
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: self.implementation.reference().map(str::to_owned),
debug_assert!(object.validate().is_ok()); validator: self
object .implementation
.validator()
.map(|validator| validator.0.clone()),
} }
/// 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",
@@ -405,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(
@@ -413,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",
@@ -435,31 +451,49 @@ 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_call(&OperationCall { .validate_arguments(&request.operation, &request.arguments)
target: OperationTarget {
object: request.object.clone(),
interface: bound.artifact.descriptor.name.clone(),
operation: request.operation.clone(),
},
arguments: request.arguments.clone(),
})
.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)?;
bound bound
.artifact .artifact
.descriptor .descriptor
.validate_return(&request.operation, value.as_ref()) .validate_result(&request.operation, &value)
.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()),
}) })
} }
@@ -470,6 +504,75 @@ 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> {
wip_protocol::validate_path(path)
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
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.
///
/// Structural path validation precedes provider resolution. For a valid
/// 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 provider callback or typed implementation.
fn call_operation(&self, request: CallRequest) -> Result<CallResult, CallError> {
wip_protocol::validate_path(&request.target.path)
.map_err(|error| CallError::new(CallErrorKind::InvalidRequest, error.to_string()))?;
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 {
@@ -491,44 +594,13 @@ 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: Vec<NamedValue>,
}
/// Successful host dispatch result.
#[derive(Debug, Clone, PartialEq)]
pub struct CallResult {
/// Encoded application value, or `None` for a no-return operation.
pub value: Option<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 request is structurally invalid.
ObjectNotFound, InvalidRequest,
/// The target path is not currently published.
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.
@@ -537,7 +609,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,
@@ -545,6 +617,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,
} }
@@ -600,30 +674,29 @@ 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 => ProtocolErrorCategory::ObjectNotFound, CallErrorKind::InvalidRequest => ProtocolErrorCode::InvalidRequest,
CallErrorKind::InterfaceMismatch => ProtocolErrorCategory::InterfaceNotFound, CallErrorKind::NotFound => ProtocolErrorCode::NotFound,
CallErrorKind::OperationNotFound => ProtocolErrorCategory::OperationNotFound, CallErrorKind::InterfaceMismatch => ProtocolErrorCode::InterfaceMismatch,
CallErrorKind::InvalidArguments => ProtocolErrorCategory::InvalidArguments, CallErrorKind::OperationNotFound => ProtocolErrorCode::OperationNotFound,
CallErrorKind::ValidatorRequired CallErrorKind::InvalidArguments => ProtocolErrorCode::InvalidArguments,
| CallErrorKind::ValidatorMismatch CallErrorKind::InvalidResult => ProtocolErrorCode::InvalidResult,
| CallErrorKind::InterfaceValidatorRequired CallErrorKind::ValidatorRequired => ProtocolErrorCode::ValidatorRequired,
| CallErrorKind::InterfaceValidatorMismatch => ProtocolErrorCategory::InvalidRequest, CallErrorKind::ValidatorMismatch => ProtocolErrorCode::ValidatorMismatch,
CallErrorKind::InvalidResult | CallErrorKind::Implementation => { CallErrorKind::InterfaceValidatorRequired => {
ProtocolErrorCategory::Internal ProtocolErrorCode::InterfaceValidatorRequired
} }
CallErrorKind::InterfaceValidatorMismatch => {
ProtocolErrorCode::InterfaceValidatorMismatch
}
CallErrorKind::PermissionDenied => ProtocolErrorCode::PermissionDenied,
CallErrorKind::Implementation => ProtocolErrorCode::Internal,
}; };
ProtocolError { ProtocolError {
category, code,
message: self.message.clone(), message: self.message.clone(),
} }
} }
+480 -329
View File
@@ -1,14 +1,15 @@
use std::cell::{Cell, RefCell}; use std::cell::Cell;
use std::collections::BTreeMap;
use std::rc::Rc;
use wip_host::{ use wip_host::{
CallErrorKind, CallRequest, InterfaceRegistry, InterfaceTarget, PublishedObject, Validator, CallError, CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget,
WipObject, ObjectProvider, PublishedObject, RegisteredInterface, Target, Validator, WipObject,
}; };
use wip_interface::{ use wip_interface::{
CodecError, HostError, NamedValue, OperationResult, TypeDeclaration, TypeExpr, Value, WipType, CodecError, HostError, OperationResult, TypeDeclaration, TypeExpr, Value, WipType, interface,
interface,
}; };
use wip_protocol::{ObjectId, ProtocolErrorCategory}; use wip_protocol::ProtocolErrorCode;
#[interface] #[interface]
pub trait Reader { pub trait Reader {
@@ -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,371 +160,499 @@ 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: Vec<NamedValue>, 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.name, reader.reference().as_str());
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(),
] ]
); );
wip_protocol::InterfaceObservation { assert!(!format!("{object:?}").contains("secret-handle"));
object: object.clone(), assert!(!format!("{object:?}").contains("/document"));
descriptors: vec![
registry.observe(writer.reference()).unwrap().descriptor,
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",
vec![],
)) ))
.expose(&provider.reader)
.unwrap(); .unwrap();
let reader_result = publication
.dispatch(request(
"document-1",
None,
reader.reference(),
None,
"label",
vec![],
))
.unwrap();
assert_eq!(writer_result.value, Some(Value::String("writer".into())));
assert_eq!(reader_result.value, Some(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",
vec![],
))
.unwrap_err();
assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch);
assert_eq!(
error.protocol_error().category,
ProtocolErrorCategory::InterfaceNotFound
);
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.id, ObjectId("empty".into()));
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",
vec![],
))
.unwrap_err();
assert_eq!(missing.kind(), CallErrorKind::OperationNotFound);
assert_eq!(
missing.protocol_error().category,
ProtocolErrorCategory::OperationNotFound
); );
let invalid = publication
.dispatch(request(
"validation",
None,
reader.reference(),
None,
"add",
vec![NamedValue {
name: "left".into(),
value: Value::String("wrong".into()),
}],
))
.unwrap_err();
assert_eq!(invalid.kind(), CallErrorKind::InvalidArguments);
assert_eq!(
invalid.protocol_error().category,
ProtocolErrorCategory::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();
vec![], 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().category,
ProtocolErrorCategory::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, None,
"label", "label",
vec![], BTreeMap::new(),
))
.unwrap_err();
assert_eq!(missing.kind(), CallErrorKind::ValidatorRequired);
let mismatch = publication
.dispatch(request(
"strict",
Some(b"stale"),
reader.reference(),
None,
"label",
vec![],
))
.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,
"label",
vec![],
)) ))
.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",
vec![],
))
.unwrap_err();
assert_eq!(missing.kind(), CallErrorKind::InterfaceValidatorRequired);
let mismatch = publication
.dispatch(request(
"mutable-interface",
None,
writer.reference(),
Some(b"schema-v0"),
"label",
vec![],
))
.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",
vec![], BTreeMap::new(),
)) ))
.unwrap(); .unwrap();
assert_eq!(result.value, Some(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 { let resolutions = provider.resolutions.get();
fn fail(&self) -> OperationResult<String> { for invalid_path in [
Err(HostError::new("backend unavailable")) "relative",
} "/trailing/",
} "/double//segment",
"/dot/./segment",
let mut registry = InterfaceRegistry::new(); "/parent/../segment",
let failing = registry.register(FailingDefinition, None).unwrap(); ] {
let publication = PublishedObject::new(Document::new("failure")) assert_failure(
.expose(&failing) provider
.unwrap(); .call_operation(request(
let error = publication invalid_path,
.dispatch(request( Some(b"stale"),
"failure", hidden.reference(),
None, Some(b"stale"),
failing.reference(), "missing",
None, BTreeMap::new(),
"fail",
vec![],
)) ))
.unwrap_err(); .unwrap_err(),
assert_eq!(error.kind(), CallErrorKind::Implementation); CallErrorKind::InvalidRequest,
assert_eq!( ProtocolErrorCode::InvalidRequest,
error.protocol_error().category, );
ProtocolErrorCategory::Internal }
assert_eq!(provider.resolutions.get(), resolutions);
assert_failure(
provider.fetch("/invalid/").unwrap_err(),
CallErrorKind::InvalidRequest,
ProtocolErrorCode::InvalidRequest,
);
assert_eq!(provider.resolutions.get(), resolutions);
assert_failure(
provider
.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,
);
provider.deny.set(true);
assert_failure(
provider
.call_operation(request(
"/document",
Some(b"object-v1"),
provider.reader.reference(),
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,
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,
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",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::Implementation,
ProtocolErrorCode::Internal,
); );
} }
@@ -1,6 +1,5 @@
use wip_host::{InterfaceRegistry, PublishedObject, WipObject}; use wip_host::{InterfaceRegistry, PublishedObject, WipObject};
use wip_interface::{OperationResult, interface}; use wip_interface::{OperationResult, interface};
use wip_protocol::ObjectId;
#[interface] #[interface]
pub trait Search { pub trait Search {
@@ -10,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"
} }
} }
@@ -1,27 +1,27 @@
error[E0277]: the trait bound `Object: Search` is not satisfied error[E0277]: the trait bound `Object: Search` is not satisfied
--> tests/ui/unimplemented_interface.rs:21:58 --> tests/ui/unimplemented_interface.rs:20:58
| |
21 | let _published = PublishedObject::new(Object).expose(&search).unwrap(); 20 | let _published = PublishedObject::new(Object).expose(&search).unwrap();
| ------ ^^^^^^^ unsatisfied trait bound | ------ ^^^^^^^ unsatisfied trait bound
| | | |
| required by a bound introduced by this call | required by a bound introduced by this call
| |
help: the trait `Search` is not implemented for `Object` help: the trait `Search` is not implemented for `Object`
--> tests/ui/unimplemented_interface.rs:10:1 --> tests/ui/unimplemented_interface.rs:9:1
| |
10 | struct Object; 9 | struct Object;
| ^^^^^^^^^^^^^ | ^^^^^^^^^^^^^
help: this trait has no implementations, consider adding one help: this trait has no implementations, consider adding one
--> tests/ui/unimplemented_interface.rs:6:1
|
6 | pub trait Search {
| ^^^^^^^^^^^^^^^^
note: required for `SearchDefinition` to implement `InterfaceImplementation<Object>`
--> tests/ui/unimplemented_interface.rs:5:1 --> tests/ui/unimplemented_interface.rs:5:1
| |
5 | #[interface] 5 | pub trait Search {
| ^^^^^^^^^^^^^^^^
note: required for `SearchDefinition` to implement `InterfaceImplementation<Object>`
--> tests/ui/unimplemented_interface.rs:4:1
|
4 | #[interface]
| ^^^^^^^^^^^^ | ^^^^^^^^^^^^
6 | pub trait Search { 5 | pub trait Search {
| ^^^^^^ | ^^^^^^
note: required by a bound in `PublishedObject::<T>::expose` note: required by a bound in `PublishedObject::<T>::expose`
--> src/lib.rs --> src/lib.rs
+153 -97
View File
@@ -168,9 +168,11 @@ fn expand_interface(attribute: TokenStream2, item: ItemTrait) -> syn::Result<Tok
#(#declaration_collectors)* #(#declaration_collectors)*
::wip_interface::wip_protocol::InterfaceDescriptor { ::wip_interface::wip_protocol::InterfaceDescriptor {
name: ::std::string::String::from(stringify!(#trait_ident)), format: ::std::string::String::from(
::wip_interface::wip_protocol::INTERFACE_FORMAT_V1,
),
documentation: #trait_doc, documentation: #trait_doc,
declarations, types: declarations,
operations: ::std::vec![#(#operation_declarations),*], operations: ::std::vec![#(#operation_declarations),*],
} }
} }
@@ -180,9 +182,12 @@ fn expand_interface(attribute: TokenStream2, item: ItemTrait) -> syn::Result<Tok
fn dispatch( fn dispatch(
implementation: &T, implementation: &T,
operation: &str, operation: &str,
arguments: ::std::vec::Vec<::wip_interface::wip_protocol::NamedValue>, arguments: ::std::collections::BTreeMap<
::std::string::String,
::wip_interface::wip_protocol::Value,
>,
) -> ::std::result::Result< ) -> ::std::result::Result<
::std::option::Option<::wip_interface::wip_protocol::Value>, ::wip_interface::wip_protocol::Value,
::wip_interface::DispatchError, ::wip_interface::DispatchError,
> { > {
let descriptor = Self::descriptor(); let descriptor = Self::descriptor();
@@ -207,9 +212,12 @@ fn expand_interface(attribute: TokenStream2, item: ItemTrait) -> syn::Result<Tok
pub fn dispatch<T: #trait_ident>( pub fn dispatch<T: #trait_ident>(
implementation: &T, implementation: &T,
operation: &str, operation: &str,
arguments: ::std::vec::Vec<::wip_interface::wip_protocol::NamedValue>, arguments: ::std::collections::BTreeMap<
::std::string::String,
::wip_interface::wip_protocol::Value,
>,
) -> ::std::result::Result< ) -> ::std::result::Result<
::std::option::Option<::wip_interface::wip_protocol::Value>, ::wip_interface::wip_protocol::Value,
::wip_interface::DispatchError, ::wip_interface::DispatchError,
> { > {
<Self as ::wip_interface::InterfaceImplementation<T>>::dispatch( <Self as ::wip_interface::InterfaceImplementation<T>>::dispatch(
@@ -523,12 +531,11 @@ fn argument_item(operation: &Operation) -> TokenStream2 {
let encode_result = if operation.unit_result { let encode_result = if operation.unit_result {
quote! { quote! {
let () = result; let () = result;
::std::result::Result::Ok(::std::option::Option::None) ::std::result::Result::Ok(::wip_interface::wip_protocol::Value::Unit)
} }
} else { } else {
quote! { quote! {
<#result_ty as ::wip_interface::WipType>::encode(result) <#result_ty as ::wip_interface::WipType>::encode(result)
.map(::std::option::Option::Some)
} }
}; };
@@ -541,9 +548,12 @@ fn argument_item(operation: &Operation) -> TokenStream2 {
impl #arguments_ident { impl #arguments_ident {
/// Decodes named logical values into typed operation arguments. /// Decodes named logical values into typed operation arguments.
pub fn decode( pub fn decode(
arguments: ::std::vec::Vec<::wip_interface::wip_protocol::NamedValue>, arguments: ::std::collections::BTreeMap<
::std::string::String,
::wip_interface::wip_protocol::Value,
>,
) -> ::std::result::Result<Self, ::wip_interface::CodecError> { ) -> ::std::result::Result<Self, ::wip_interface::CodecError> {
let mut decoder = ::wip_interface::__private::RecordDecoder::new(arguments)?; let mut decoder = ::wip_interface::__private::RecordDecoder::new(arguments);
let decoded = Self { let decoded = Self {
#(#decoders,)* #(#decoders,)*
}; };
@@ -555,7 +565,7 @@ fn argument_item(operation: &Operation) -> TokenStream2 {
pub fn encode_result( pub fn encode_result(
result: #result_ty, result: #result_ty,
) -> ::std::result::Result< ) -> ::std::result::Result<
::std::option::Option<::wip_interface::wip_protocol::Value>, ::wip_interface::wip_protocol::Value,
::wip_interface::CodecError, ::wip_interface::CodecError,
> { > {
#encode_result #encode_result
@@ -575,22 +585,18 @@ fn operation_declaration(operation: &Operation) -> TokenStream2 {
quote! { quote! {
::wip_interface::wip_protocol::ParameterDeclaration { ::wip_interface::wip_protocol::ParameterDeclaration {
name: ::std::string::String::from(#parameter_name), name: ::std::string::String::from(#parameter_name),
required: !#optional,
documentation: #parameter_doc, documentation: #parameter_doc,
ty: <#ty as ::wip_interface::WipType>::type_expr(), r#type: <#ty as ::wip_interface::WipType>::type_expr(),
optional: #optional,
} }
} }
}); });
let returns = if operation.unit_result {
quote!(::std::option::Option::None)
} else {
let result_ty = &operation.result_ty; let result_ty = &operation.result_ty;
let return_doc = documentation_tokens(operation.return_documentation.as_ref()); let return_doc = documentation_tokens(operation.return_documentation.as_ref());
quote! { let returns = quote! {
::std::option::Option::Some(::wip_interface::wip_protocol::ReturnDeclaration { ::wip_interface::wip_protocol::ReturnDeclaration {
documentation: #return_doc, documentation: #return_doc,
ty: <#result_ty as ::wip_interface::WipType>::type_expr(), r#type: <#result_ty as ::wip_interface::WipType>::type_expr(),
})
} }
}; };
@@ -646,7 +652,7 @@ fn dispatch_arm(operation: &Operation) -> TokenStream2 {
let encoded = #arguments_ident::encode_result(result) let encoded = #arguments_ident::encode_result(result)
.map_err(::wip_interface::DispatchError::Encode)?; .map_err(::wip_interface::DispatchError::Encode)?;
descriptor descriptor
.validate_return(#operation_name, encoded.as_ref()) .validate_result(#operation_name, &encoded)
.map_err(::wip_interface::DispatchError::InvalidResult)?; .map_err(::wip_interface::DispatchError::InvalidResult)?;
::std::result::Result::Ok(encoded) ::std::result::Result::Ok(encoded)
} }
@@ -747,9 +753,9 @@ fn derive_record(
quote! { quote! {
::wip_interface::wip_protocol::FieldDeclaration { ::wip_interface::wip_protocol::FieldDeclaration {
name: ::std::string::String::from(#name), name: ::std::string::String::from(#name),
required: !#optional,
documentation: #documentation, documentation: #documentation,
ty: <#ty as ::wip_interface::WipType>::type_expr(), r#type: <#ty as ::wip_interface::WipType>::type_expr(),
optional: #optional,
} }
} }
}); });
@@ -765,18 +771,18 @@ fn derive_record(
if field.optional { if field.optional {
quote! { quote! {
if let ::std::option::Option::Some(value) = #field_ident { if let ::std::option::Option::Some(value) = #field_ident {
fields.push(::wip_interface::wip_protocol::NamedValue { fields.insert(
name: ::std::string::String::from(#field_name), ::std::string::String::from(#field_name),
value: <#ty as ::wip_interface::WipType>::encode(value)?, <#ty as ::wip_interface::WipType>::encode(value)?,
}); );
} }
} }
} else { } else {
quote! { quote! {
fields.push(::wip_interface::wip_protocol::NamedValue { fields.insert(
name: ::std::string::String::from(#field_name), ::std::string::String::from(#field_name),
value: <#ty as ::wip_interface::WipType>::encode(#field_ident)?, <#ty as ::wip_interface::WipType>::encode(#field_ident)?,
}); );
} }
} }
}); });
@@ -794,9 +800,9 @@ fn derive_record(
Ok(quote! { Ok(quote! {
impl ::wip_interface::WipType for #ident { impl ::wip_interface::WipType for #ident {
fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr { fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr {
::wip_interface::wip_protocol::TypeExpr::Named( ::wip_interface::wip_protocol::TypeExpr::Named {
::std::string::String::from(#type_name), name: ::std::string::String::from(#type_name),
) }
} }
fn collect_declarations( fn collect_declarations(
@@ -804,13 +810,13 @@ fn derive_record(
::wip_interface::wip_protocol::TypeDeclaration, ::wip_interface::wip_protocol::TypeDeclaration,
>, >,
) { ) {
let declaration = ::wip_interface::wip_protocol::TypeDeclaration::Record( let declaration = ::wip_interface::wip_protocol::TypeDeclaration {
::wip_interface::wip_protocol::RecordDeclaration {
name: ::std::string::String::from(#type_name), name: ::std::string::String::from(#type_name),
documentation: #declaration_doc, documentation: #declaration_doc,
definition: ::wip_interface::wip_protocol::TypeExpr::Record {
fields: ::std::vec![#(#field_declarations),*], fields: ::std::vec![#(#field_declarations),*],
}, },
); };
if ::wip_interface::__private::register_declaration(declarations, declaration) { if ::wip_interface::__private::register_declaration(declarations, declaration) {
#(#dependency_collectors)* #(#dependency_collectors)*
} }
@@ -823,7 +829,7 @@ fn derive_record(
::wip_interface::CodecError, ::wip_interface::CodecError,
> { > {
let Self { #(#destructured_fields),* } = self; let Self { #(#destructured_fields),* } = self;
let mut fields = ::std::vec::Vec::new(); let mut fields = ::std::collections::BTreeMap::new();
#(#encoders)* #(#encoders)*
::std::result::Result::Ok( ::std::result::Result::Ok(
::wip_interface::wip_protocol::Value::Record(fields), ::wip_interface::wip_protocol::Value::Record(fields),
@@ -835,7 +841,7 @@ fn derive_record(
) -> ::std::result::Result<Self, ::wip_interface::CodecError> { ) -> ::std::result::Result<Self, ::wip_interface::CodecError> {
match value { match value {
::wip_interface::wip_protocol::Value::Record(fields) => { ::wip_interface::wip_protocol::Value::Record(fields) => {
let mut decoder = ::wip_interface::__private::RecordDecoder::new(fields)?; let mut decoder = ::wip_interface::__private::RecordDecoder::new(fields);
let decoded = Self { let decoded = Self {
#(#decoders,)* #(#decoders,)*
}; };
@@ -870,14 +876,14 @@ fn derive_enum(
.variants .variants
.iter() .iter()
.all(|variant| matches!(variant.fields, Fields::Unit)); .all(|variant| matches!(variant.fields, Fields::Unit));
let all_payload = !data.variants.is_empty() let valid_union = data.variants.iter().all(|variant| {
&& data.variants.iter().all(|variant| { matches!(variant.fields, Fields::Unit)
matches!(&variant.fields, Fields::Unnamed(fields) if fields.unnamed.len() == 1) || matches!(&variant.fields, Fields::Unnamed(fields) if fields.unnamed.len() == 1)
}); });
if all_unit { if all_unit {
derive_unit_enum(ident, type_name, type_documentation, data) derive_unit_enum(ident, type_name, type_documentation, data)
} else if all_payload { } else if valid_union {
derive_union_enum(ident, type_name, type_documentation, data) derive_union_enum(ident, type_name, type_documentation, data)
} else { } else {
let span = data let span = data
@@ -890,7 +896,7 @@ fn derive_enum(
.map_or_else(|| data.enum_token.span, Spanned::span); .map_or_else(|| data.enum_token.span, Spanned::span);
Err(Error::new( Err(Error::new(
span, span,
"a `WipType` enum must contain either only unit variants or only single-field tuple variants; mixed and named-field variants are unsupported", "a `WipType` enum must contain unit variants and/or single-field tuple variants; named-field and multi-field variants are unsupported",
)) ))
} }
} }
@@ -906,7 +912,7 @@ fn derive_unit_enum(
let name = source_name(&variant.ident); let name = source_name(&variant.ident);
let documentation = documentation_tokens(documentation(&variant.attrs).as_ref()); let documentation = documentation_tokens(documentation(&variant.attrs).as_ref());
quote! { quote! {
::wip_interface::wip_protocol::EnumVariantDeclaration { ::wip_interface::wip_protocol::EnumCase {
name: ::std::string::String::from(#name), name: ::std::string::String::from(#name),
documentation: #documentation, documentation: #documentation,
} }
@@ -926,9 +932,9 @@ fn derive_unit_enum(
Ok(quote! { Ok(quote! {
impl ::wip_interface::WipType for #ident { impl ::wip_interface::WipType for #ident {
fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr { fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr {
::wip_interface::wip_protocol::TypeExpr::Named( ::wip_interface::wip_protocol::TypeExpr::Named {
::std::string::String::from(#type_name), name: ::std::string::String::from(#type_name),
) }
} }
fn collect_declarations( fn collect_declarations(
@@ -936,13 +942,13 @@ fn derive_unit_enum(
::wip_interface::wip_protocol::TypeDeclaration, ::wip_interface::wip_protocol::TypeDeclaration,
>, >,
) { ) {
let declaration = ::wip_interface::wip_protocol::TypeDeclaration::Enum( let declaration = ::wip_interface::wip_protocol::TypeDeclaration {
::wip_interface::wip_protocol::EnumDeclaration {
name: ::std::string::String::from(#type_name), name: ::std::string::String::from(#type_name),
documentation: #declaration_doc, documentation: #declaration_doc,
variants: ::std::vec![#(#declarations),*], definition: ::wip_interface::wip_protocol::TypeExpr::Enum {
cases: ::std::vec![#(#declarations),*],
}, },
); };
let _ = ::wip_interface::__private::register_declaration( let _ = ::wip_interface::__private::register_declaration(
declarations, declarations,
declaration, declaration,
@@ -959,7 +965,7 @@ fn derive_unit_enum(
#(#encoders,)* #(#encoders,)*
}; };
::std::result::Result::Ok( ::std::result::Result::Ok(
::wip_interface::wip_protocol::Value::Enum( ::wip_interface::wip_protocol::Value::String(
::std::string::String::from(variant), ::std::string::String::from(variant),
), ),
) )
@@ -969,7 +975,7 @@ fn derive_unit_enum(
value: ::wip_interface::wip_protocol::Value, value: ::wip_interface::wip_protocol::Value,
) -> ::std::result::Result<Self, ::wip_interface::CodecError> { ) -> ::std::result::Result<Self, ::wip_interface::CodecError> {
match value { match value {
::wip_interface::wip_protocol::Value::Enum(variant) => { ::wip_interface::wip_protocol::Value::String(variant) => {
match variant.as_str() { match variant.as_str() {
#(#decoders,)* #(#decoders,)*
_ => ::std::result::Result::Err( _ => ::std::result::Result::Err(
@@ -998,35 +1004,36 @@ fn derive_union_enum(
struct Variant<'a> { struct Variant<'a> {
ident: &'a Ident, ident: &'a Ident,
name: String, name: String,
ty: &'a Type, payload: Option<&'a Type>,
documentation: Option<Documentation>, documentation: Option<Documentation>,
} }
let mut variants = Vec::new(); let mut variants = Vec::new();
for variant in &data.variants { for variant in &data.variants {
let Fields::Unnamed(fields) = &variant.fields else { let payload = match &variant.fields {
return Err(Error::new_spanned( Fields::Unit => None,
variant, Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {
"a `WipType` union variant must contain exactly one unnamed field", let field = fields.unnamed.first().expect("length checked");
));
};
let Some(field) = fields.unnamed.first() else {
return Err(Error::new_spanned(
fields,
"a `WipType` union variant must contain exactly one unnamed field",
));
};
if direct_option(&field.ty)?.is_some() || contains_option(&field.ty) { if direct_option(&field.ty)?.is_some() || contains_option(&field.ty) {
return Err(Error::new_spanned( return Err(Error::new_spanned(
&field.ty, &field.ty,
"`Option<T>` is only supported directly on DTO fields and operation parameters", "`Option<T>` is only supported directly on DTO fields and operation parameters",
)); ));
} }
validate_value_type(&field.ty, "union variant payload")?; validate_value_type(&field.ty, "union case payload")?;
Some(&field.ty)
}
_ => {
return Err(Error::new_spanned(
variant,
"a `WipType` union case must be unit-like or contain exactly one unnamed payload",
));
}
};
variants.push(Variant { variants.push(Variant {
ident: &variant.ident, ident: &variant.ident,
name: source_name(&variant.ident), name: source_name(&variant.ident),
ty: &field.ty, payload,
documentation: documentation(&variant.attrs), documentation: documentation(&variant.attrs),
}); });
} }
@@ -1035,50 +1042,95 @@ fn derive_union_enum(
let declarations = variants.iter().map(|variant| { let declarations = variants.iter().map(|variant| {
let name = &variant.name; let name = &variant.name;
let documentation = documentation_tokens(variant.documentation.as_ref()); let documentation = documentation_tokens(variant.documentation.as_ref());
let ty = variant.ty; let payload = match variant.payload {
Some(ty) => quote! {
::std::option::Option::Some(
<#ty as ::wip_interface::WipType>::type_expr(),
)
},
None => quote!(::std::option::Option::None),
};
quote! { quote! {
::wip_interface::wip_protocol::UnionVariantDeclaration { ::wip_interface::wip_protocol::UnionCase {
name: ::std::string::String::from(#name), name: ::std::string::String::from(#name),
documentation: #documentation, documentation: #documentation,
ty: <#ty as ::wip_interface::WipType>::type_expr(), payload: #payload,
} }
} }
}); });
let dependency_collectors = variants.iter().map(|variant| { let dependency_collectors = variants.iter().filter_map(|variant| {
let ty = variant.ty; variant.payload.map(
quote!(<#ty as ::wip_interface::WipType>::collect_declarations(declarations);) |ty| quote!(<#ty as ::wip_interface::WipType>::collect_declarations(declarations);),
)
}); });
let encoders = variants.iter().map(|variant| { let encoders = variants.iter().map(|variant| {
let variant_ident = variant.ident; let variant_ident = variant.ident;
let name = &variant.name; let name = &variant.name;
let ty = variant.ty; match variant.payload {
quote! { Some(ty) => quote! {
Self::#variant_ident(value) => { Self::#variant_ident(value) => {
::wip_interface::wip_protocol::Value::Union { let mut fields = ::std::collections::BTreeMap::new();
variant: ::std::string::String::from(#name), fields.insert(
value: ::std::boxed::Box::new( ::std::string::String::from(
<#ty as ::wip_interface::WipType>::encode(value)?, ::wip_interface::wip_protocol::UNION_CASE_FIELD,
), ),
::wip_interface::wip_protocol::Value::String(
::std::string::String::from(#name),
),
);
fields.insert(
::std::string::String::from(
::wip_interface::wip_protocol::UNION_VALUE_FIELD,
),
<#ty as ::wip_interface::WipType>::encode(value)?,
);
::wip_interface::wip_protocol::Value::Record(fields)
} }
},
None => quote! {
Self::#variant_ident => {
let mut fields = ::std::collections::BTreeMap::new();
fields.insert(
::std::string::String::from(
::wip_interface::wip_protocol::UNION_CASE_FIELD,
),
::wip_interface::wip_protocol::Value::String(
::std::string::String::from(#name),
),
);
::wip_interface::wip_protocol::Value::Record(fields)
} }
},
} }
}); });
let decoders = variants.iter().map(|variant| { let decoders = variants.iter().map(|variant| {
let variant_ident = variant.ident; let variant_ident = variant.ident;
let name = &variant.name; let name = &variant.name;
let ty = variant.ty; match variant.payload {
quote! { Some(ty) => quote! {
#name => <#ty as ::wip_interface::WipType>::decode(*value) #name => {
.map(Self::#variant_ident) let value = decoder.required::<#ty>(
::wip_interface::wip_protocol::UNION_VALUE_FIELD,
)?;
decoder.finish()?;
::std::result::Result::Ok(Self::#variant_ident(value))
}
},
None => quote! {
#name => {
decoder.finish()?;
::std::result::Result::Ok(Self::#variant_ident)
}
},
} }
}); });
Ok(quote! { Ok(quote! {
impl ::wip_interface::WipType for #ident { impl ::wip_interface::WipType for #ident {
fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr { fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr {
::wip_interface::wip_protocol::TypeExpr::Named( ::wip_interface::wip_protocol::TypeExpr::Named {
::std::string::String::from(#type_name), name: ::std::string::String::from(#type_name),
) }
} }
fn collect_declarations( fn collect_declarations(
@@ -1086,13 +1138,13 @@ fn derive_union_enum(
::wip_interface::wip_protocol::TypeDeclaration, ::wip_interface::wip_protocol::TypeDeclaration,
>, >,
) { ) {
let declaration = ::wip_interface::wip_protocol::TypeDeclaration::Union( let declaration = ::wip_interface::wip_protocol::TypeDeclaration {
::wip_interface::wip_protocol::UnionDeclaration {
name: ::std::string::String::from(#type_name), name: ::std::string::String::from(#type_name),
documentation: #declaration_doc, documentation: #declaration_doc,
variants: ::std::vec![#(#declarations),*], definition: ::wip_interface::wip_protocol::TypeExpr::Union {
cases: ::std::vec![#(#declarations),*],
}, },
); };
if ::wip_interface::__private::register_declaration(declarations, declaration) { if ::wip_interface::__private::register_declaration(declarations, declaration) {
#(#dependency_collectors)* #(#dependency_collectors)*
} }
@@ -1114,26 +1166,30 @@ fn derive_union_enum(
value: ::wip_interface::wip_protocol::Value, value: ::wip_interface::wip_protocol::Value,
) -> ::std::result::Result<Self, ::wip_interface::CodecError> { ) -> ::std::result::Result<Self, ::wip_interface::CodecError> {
match value { match value {
::wip_interface::wip_protocol::Value::Union { variant, value } => { ::wip_interface::wip_protocol::Value::Record(fields) => {
match variant.as_str() { let mut decoder =
::wip_interface::__private::RecordDecoder::new(fields);
let case = decoder.required::<::std::string::String>(
::wip_interface::wip_protocol::UNION_CASE_FIELD,
)?;
match case.as_str() {
#(#decoders,)* #(#decoders,)*
_ => ::std::result::Result::Err( _ => ::std::result::Result::Err(
::wip_interface::__private::decode_unknown_variant( ::wip_interface::__private::decode_unknown_variant(
#type_name, #type_name,
variant, case,
), ),
), ),
} }
} }
actual => ::std::result::Result::Err( actual => ::std::result::Result::Err(
::wip_interface::__private::decode_type_mismatch("union", actual), ::wip_interface::__private::decode_type_mismatch("union record", actual),
), ),
} }
} }
} }
}) })
} }
fn optional_type(ty: &Type, context: &str) -> syn::Result<(bool, Type)> { fn optional_type(ty: &Type, context: &str) -> syn::Result<(bool, Type)> {
if let Some(inner) = direct_option(ty)? { if let Some(inner) = direct_option(ty)? {
if contains_option(&inner) { if contains_option(&inner) {
+207 -99
View File
@@ -30,18 +30,18 @@
//! //!
//! | Rust type | Protocol type/value | //! | Rust type | Protocol type/value |
//! | --- | --- | //! | --- | --- |
//! | `()` as an operation result | no return declaration / no value | //! | `()` | `Unit` |
//! | `bool` | `Boolean` | //! | `bool` | `Boolean` |
//! | signed and unsigned integer primitives | JavaScript-lossless `Integer` | //! | signed and unsigned integer primitives | JavaScript-lossless `Integer` |
//! | `f32`, `f64` | finite `Number` | //! | `f32`, `f64` | finite `Number` |
//! | `String` | `String` | //! | `String` | `String` |
//! | [`Bytes`] | `Bytes` | //! | [`Bytes`] | `Bytes` |
//! | [`wip_protocol::JsonValue`] | `Json` | //! | [`JsonValue`] | schema-neutral `Json` value tree |
//! | [`Entry<K, V>`] | `Entry<K, V>` | //! | [`Entry`] | canonical absolute Worldspace path (`Entry`) |
//! | `Vec<T>` | `List<T>` | //! | `Vec<T>` | `List<T>` |
//! | a named-field struct deriving [`WipType`] | named record | //! | a named-field struct deriving [`WipType`] | named record |
//! | an all-unit enum deriving [`WipType`] | named enum | //! | an all-unit enum deriving [`WipType`] | named enum |
//! | an all-newtype enum deriving [`WipType`] | named tagged union | //! | an enum mixing unit and single-payload cases deriving [`WipType`] | named union |
//! //!
//! `Option<T>` is supported only directly as a record field or operation //! `Option<T>` is supported only directly as a record field or operation
//! parameter. It means omission, not a null value. A domain failure belongs in //! parameter. It means omission, not a null value. A domain failure belongs in
@@ -49,33 +49,44 @@
//! [`HostError`] is reserved for host/runtime failure, so the required //! [`HostError`] is reserved for host/runtime failure, so the required
//! [`OperationResult`] convention keeps those two failure classes distinct. //! [`OperationResult`] convention keeps those two failure classes distinct.
//! //!
//! # Migration to canonical values
//!
//! Earlier releases exposed `Entry<K, V>` and semantic `Value::Json`,
//! `Value::Entry`, `Value::Enum`, and `Value::Union` variants. Those forms are
//! intentionally gone: [`Entry`] is now one validated absolute Worldspace path,
//! while JSON, enum, and union meaning comes only from the descriptor. Codecs
//! exchange only raw [`Value`] strings, records, lists, and primitives. Unit
//! operation results now cross the boundary as [`Value::Unit`] rather than an
//! absent value.
//!
//! # Supported interface syntax //! # Supported interface syntax
//! //!
//! An interface is a public, non-generic trait whose operations use exactly //! An interface is a public, non-generic trait whose operations use exactly
//! `&self`, simple named owned parameters, and an explicit //! `&self`, simple named owned parameters, and an explicit
//! `OperationResult<T>`. `WipType` derives support named-field structs, //! `OperationResult<T>`. `WipType` derives support named-field structs,
//! all-unit enums, and all-single-payload enums. //! all-unit enums as protocol enums, and enums made from any mix of unit and
//! single-payload cases as protocol unions.
//! //!
//! The macros reject trait inheritance and associated items; const, async, //! The macros reject trait inheritance and associated items; const, async,
//! unsafe, extern, variadic, generic, default-bodied, or non-`&self` methods; //! unsafe, extern, variadic, generic, default-bodied, or non-`&self` methods;
//! borrowed values and custom lifetimes; tuple, unit, or generic structs; //! borrowed values and custom lifetimes; tuple, unit, or generic structs;
//! mixed or named-field enum variants; explicit discriminants; Rust unions; //! unsupported named-field or multi-field enum variants; explicit discriminants; Rust unions;
//! and `Option` anywhere except a direct DTO field or operation parameter. //! and `Option` anywhere except a direct DTO field or operation parameter.
//! Unsupported inputs produce source-located compile errors rather than //! Unsupported inputs produce source-located compile errors rather than
//! generating a partial interface. //! generating a partial interface.
#![deny(missing_docs)] #![deny(missing_docs)]
use std::collections::BTreeMap;
use std::error::Error; use std::error::Error;
use std::fmt::{self, Display, Formatter}; use std::fmt::{self, Display, Formatter};
pub use wip_interface_macros::{WipType, interface}; pub use wip_interface_macros::{WipType, interface};
pub use wip_protocol; pub use wip_protocol;
pub use wip_protocol::{ pub use wip_protocol::{
Documentation, EnumDeclaration, EnumVariantDeclaration, FieldDeclaration, InterfaceDescriptor, Documentation, EnumCase, FieldDeclaration, InterfaceDescriptor, MAX_SAFE_INTEGER,
JsonMember, JsonValue, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NamedValue, OperationDeclaration, MIN_SAFE_INTEGER, OperationDeclaration, ParameterDeclaration, ReturnDeclaration,
ParameterDeclaration, RecordDeclaration, ReturnDeclaration, TypeDeclaration, TypeExpr, TypeDeclaration, TypeExpr, UnionCase, ValidationError, Value,
UnionDeclaration, UnionVariantDeclaration, ValidationError, Value,
}; };
/// Converts one Rust application type to and from its protocol representation. /// Converts one Rust application type to and from its protocol representation.
@@ -118,8 +129,8 @@ pub trait InterfaceImplementation<T>: InterfaceDefinition {
fn dispatch( fn dispatch(
implementation: &T, implementation: &T,
operation: &str, operation: &str,
arguments: Vec<NamedValue>, arguments: BTreeMap<String, Value>,
) -> Result<Option<Value>, DispatchError>; ) -> Result<Value, DispatchError>;
} }
/// Result convention required for generated interface operations. /// Result convention required for generated interface operations.
@@ -281,13 +292,101 @@ impl From<Bytes> for Vec<u8> {
} }
} }
/// A typed protocol key/value entry. /// A canonical absolute path to an entry in the current Worldspace.
#[derive(Debug, Clone, PartialEq, Eq)] ///
pub struct Entry<K, V> { /// This replaces the pre-canonical `Entry<K, V>` key/value pair. Construct an
/// Entry key. /// entry from a path such as `/articles/1`; application data associated with
pub key: K, /// that entry belongs in an ordinary record alongside this value.
/// Entry value. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub value: V, pub struct Entry(String);
impl Entry {
/// Validates and creates an entry path.
pub fn new(path: impl Into<String>) -> Result<Self, CodecError> {
let path = path.into();
validate_entry_path(&path)?;
Ok(Self(path))
}
/// Returns the canonical absolute Worldspace path.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
/// Consumes the entry and returns its path.
#[must_use]
pub fn into_string(self) -> String {
self.0
}
}
impl TryFrom<String> for Entry {
type Error = CodecError;
fn try_from(path: String) -> Result<Self, Self::Error> {
Self::new(path)
}
}
impl std::str::FromStr for Entry {
type Err = CodecError;
fn from_str(path: &str) -> Result<Self, Self::Err> {
Self::new(path)
}
}
/// One member in a [`JsonValue::Object`].
#[derive(Debug, Clone, PartialEq)]
pub struct JsonMember {
/// Member name.
pub name: String,
/// Member value.
pub value: JsonValue,
}
/// JSON data represented independently of any application schema.
///
/// Its codec uses the ordinary schema-neutral [`Value`] variants. JSON null is
/// [`Value::Unit`], arrays are [`Value::List`], and objects are
/// [`Value::Record`]. Protocol bytes have no JSON representation and decoding
/// a tree containing [`Value::Bytes`] is rejected.
#[derive(Debug, Clone, PartialEq)]
pub enum JsonValue {
/// JSON null.
Null,
/// A JSON boolean.
Boolean(bool),
/// An interoperable integral JSON number.
Integer(i64),
/// A finite non-integral JSON number.
Number(f64),
/// A JSON string.
String(String),
/// A JSON array.
Array(Vec<JsonValue>),
/// A JSON object. Duplicate member names are rejected while encoding.
Object(Vec<JsonMember>),
}
impl WipType for () {
fn type_expr() -> TypeExpr {
TypeExpr::Unit
}
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> {
Ok(Value::Unit)
}
fn decode(value: Value) -> Result<Self, CodecError> {
match value {
Value::Unit => Ok(()),
other => Err(CodecError::expected("unit", &other)),
}
}
} }
impl WipType for bool { impl WipType for bool {
@@ -456,52 +555,87 @@ impl WipType for JsonValue {
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {} fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> { fn encode(self) -> Result<Value, CodecError> {
validate_json(&self)?; encode_json(self)
Ok(Value::Json(self))
} }
fn decode(value: Value) -> Result<Self, CodecError> { fn decode(value: Value) -> Result<Self, CodecError> {
match value { decode_json(value)
Value::Json(value) => {
validate_json(&value)?;
Ok(value)
} }
other => Err(CodecError::expected("json", &other)), }
fn encode_json(value: JsonValue) -> Result<Value, CodecError> {
match value {
JsonValue::Null => Ok(Value::Unit),
JsonValue::Boolean(value) => Ok(Value::Boolean(value)),
JsonValue::Integer(value) => {
ensure_safe_integer(value)?;
Ok(Value::Integer(value))
}
JsonValue::Number(value) => {
ensure_finite(value)?;
Ok(Value::Number(value))
}
JsonValue::String(value) => Ok(Value::String(value)),
JsonValue::Array(values) => values
.into_iter()
.enumerate()
.map(|(index, value)| encode_json(value).map_err(|error| error.at(index.to_string())))
.collect::<Result<Vec<_>, _>>()
.map(Value::List),
JsonValue::Object(members) => {
let mut fields = BTreeMap::new();
for member in members {
let name = member.name;
let value = encode_json(member.value).map_err(|error| error.at(name.clone()))?;
if fields.insert(name.clone(), value).is_some() {
return Err(CodecError::new(format!("duplicate JSON member `{name}`")).at(name));
}
}
Ok(Value::Record(fields))
} }
} }
} }
fn validate_json(value: &JsonValue) -> Result<(), CodecError> { fn decode_json(value: Value) -> Result<JsonValue, CodecError> {
match value { match value {
JsonValue::Integer(value) => ensure_safe_integer(*value), Value::Unit => Ok(JsonValue::Null),
JsonValue::Number(value) => ensure_finite(*value), Value::Boolean(value) => Ok(JsonValue::Boolean(value)),
JsonValue::Array(values) => { Value::Integer(value) => {
for (index, value) in values.iter().enumerate() { ensure_safe_integer(value)?;
validate_json(value).map_err(|error| error.at(index.to_string()))?; Ok(JsonValue::Integer(value))
} }
Ok(()) Value::Number(value) => {
ensure_finite(value)?;
Ok(JsonValue::Number(value))
} }
JsonValue::Object(members) => { Value::String(value) => Ok(JsonValue::String(value)),
let mut names = std::collections::HashSet::new(); Value::Bytes(_) => Err(CodecError::new("bytes are not valid inside JSON")),
for member in members { Value::List(values) => values
if !names.insert(member.name.as_str()) { .into_iter()
return Err(CodecError::new(format!( .enumerate()
"duplicate JSON member `{}`", .map(|(index, value)| decode_json(value).map_err(|error| error.at(index.to_string())))
member.name .collect::<Result<Vec<_>, _>>()
)) .map(JsonValue::Array),
.at(member.name.clone())); Value::Record(fields) => fields
} .into_iter()
validate_json(&member.value).map_err(|error| error.at(member.name.clone()))?; .map(|(name, value)| {
} decode_json(value)
Ok(()) .map(|value| JsonMember {
} name: name.clone(),
JsonValue::Null | JsonValue::Boolean(_) | JsonValue::String(_) => Ok(()), value,
})
.map_err(|error| error.at(name))
})
.collect::<Result<Vec<_>, _>>()
.map(JsonValue::Object),
} }
} }
impl<T: WipType> WipType for Vec<T> { impl<T: WipType> WipType for Vec<T> {
fn type_expr() -> TypeExpr { fn type_expr() -> TypeExpr {
TypeExpr::List(Box::new(T::type_expr())) TypeExpr::List {
items: Box::new(T::type_expr()),
}
} }
fn collect_declarations(declarations: &mut Vec<TypeDeclaration>) { fn collect_declarations(declarations: &mut Vec<TypeDeclaration>) {
@@ -528,37 +662,31 @@ impl<T: WipType> WipType for Vec<T> {
} }
} }
impl<K: WipType, V: WipType> WipType for Entry<K, V> { impl WipType for Entry {
fn type_expr() -> TypeExpr { fn type_expr() -> TypeExpr {
TypeExpr::Entry { TypeExpr::Entry
key: Box::new(K::type_expr()),
value: Box::new(V::type_expr()),
}
} }
fn collect_declarations(declarations: &mut Vec<TypeDeclaration>) { fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
K::collect_declarations(declarations);
V::collect_declarations(declarations);
}
fn encode(self) -> Result<Value, CodecError> { fn encode(self) -> Result<Value, CodecError> {
Ok(Value::Entry { validate_entry_path(&self.0)?;
key: Box::new(self.key.encode().map_err(|error| error.at("key"))?), Ok(Value::String(self.0))
value: Box::new(self.value.encode().map_err(|error| error.at("value"))?),
})
} }
fn decode(value: Value) -> Result<Self, CodecError> { fn decode(value: Value) -> Result<Self, CodecError> {
let Value::Entry { key, value } = value else { let Value::String(path) = value else {
return Err(CodecError::expected("entry", &value)); return Err(CodecError::expected("entry path string", &value));
}; };
Ok(Self { Self::new(path)
key: K::decode(*key).map_err(|error| error.at("key"))?,
value: V::decode(*value).map_err(|error| error.at("value"))?,
})
} }
} }
fn validate_entry_path(path: &str) -> Result<(), CodecError> {
wip_protocol::validate_path(path)
.map_err(|error| CodecError::new(format!("invalid canonical Worldspace path: {error}")))
}
/// Implementation details used by code emitted from the facade's macros. /// Implementation details used by code emitted from the facade's macros.
/// ///
/// These APIs are public only because macro expansions execute in the /// These APIs are public only because macro expansions execute in the
@@ -566,12 +694,9 @@ impl<K: WipType, V: WipType> WipType for Entry<K, V> {
/// and are not intended for direct use. /// and are not intended for direct use.
#[doc(hidden)] #[doc(hidden)]
pub mod __private { pub mod __private {
use std::collections::HashSet; use std::collections::BTreeMap;
use wip_protocol::{ use wip_protocol::{InterfaceDescriptor, TypeDeclaration, ValidationError, Value};
InterfaceDescriptor, NamedValue, ObjectId, OperationCall, OperationTarget, TypeDeclaration,
ValidationError, Value,
};
use crate::{CodecError, Documentation, WipType}; use crate::{CodecError, Documentation, WipType};
@@ -606,16 +731,9 @@ pub mod __private {
pub fn validate_arguments( pub fn validate_arguments(
descriptor: &InterfaceDescriptor, descriptor: &InterfaceDescriptor,
operation: &str, operation: &str,
arguments: &[NamedValue], arguments: &BTreeMap<String, Value>,
) -> Result<(), ValidationError> { ) -> Result<(), ValidationError> {
descriptor.validate_call(&OperationCall { descriptor.validate_arguments(operation, arguments)
target: OperationTarget {
object: ObjectId("<typed-dispatch>".into()),
interface: descriptor.name.clone(),
operation: operation.into(),
},
arguments: arguments.to_vec(),
})
} }
/// Builds a codec error for a generated structural mismatch. /// Builds a codec error for a generated structural mismatch.
@@ -633,21 +751,13 @@ pub mod __private {
/// Decoder for a protocol record or operation's named argument collection. /// Decoder for a protocol record or operation's named argument collection.
pub struct RecordDecoder { pub struct RecordDecoder {
fields: Vec<NamedValue>, fields: BTreeMap<String, Value>,
} }
impl RecordDecoder { impl RecordDecoder {
/// Starts decoding record fields or named operation arguments and /// Starts decoding record fields or named operation arguments.
/// rejects duplicate names. pub fn new(fields: BTreeMap<String, Value>) -> Self {
pub fn new(fields: Vec<NamedValue>) -> Result<Self, CodecError> { Self { fields }
let mut seen = HashSet::new();
for field in &fields {
if !seen.insert(field.name.clone()) {
return Err(CodecError::new(format!("duplicate field `{}`", field.name))
.at(field.name.clone()));
}
}
Ok(Self { fields })
} }
/// Decodes a required field. /// Decodes a required field.
@@ -668,17 +778,15 @@ pub mod __private {
/// Rejects any fields that were not consumed by generated code. /// Rejects any fields that were not consumed by generated code.
pub fn finish(self) -> Result<(), CodecError> { pub fn finish(self) -> Result<(), CodecError> {
if let Some(field) = self.fields.first() { if let Some(name) = self.fields.keys().next() {
Err(CodecError::new(format!("unknown field `{}`", field.name)) Err(CodecError::new(format!("unknown field `{name}`")).at(name.clone()))
.at(field.name.clone()))
} else { } else {
Ok(()) Ok(())
} }
} }
fn take(&mut self, name: &str) -> Option<Value> { fn take(&mut self, name: &str) -> Option<Value> {
let index = self.fields.iter().position(|field| field.name == name)?; self.fields.remove(name)
Some(self.fields.remove(index).value)
} }
} }
} }
+115 -52
View File
@@ -1,8 +1,7 @@
use wip_interface::wip_protocol::{ use std::collections::BTreeMap;
EnumDeclaration, JsonMember, JsonValue, NamedValue, RecordDeclaration, TypeDeclaration,
TypeExpr, UnionDeclaration, Value, use wip_interface::wip_protocol::{TypeExpr, UNION_CASE_FIELD, UNION_VALUE_FIELD, Value};
}; use wip_interface::{Bytes, Entry, JsonMember, JsonValue, WipType};
use wip_interface::{Bytes, Entry, WipType};
/// A request record. /// A request record.
#[derive(Debug, Clone, PartialEq, WipType)] #[derive(Debug, Clone, PartialEq, WipType)]
@@ -11,8 +10,8 @@ struct Request {
title: String, title: String,
/// Optional priority. /// Optional priority.
priority: Option<i32>, priority: Option<i32>,
/// Related entries. /// Related Worldspace entries.
related: Vec<Entry<String, bool>>, related: Vec<Entry>,
/// Opaque bytes. /// Opaque bytes.
body: Bytes, body: Bytes,
/// Arbitrary metadata. /// Arbitrary metadata.
@@ -38,33 +37,53 @@ struct Receipt {
/// Application-level publication outcome. /// Application-level publication outcome.
#[derive(Debug, Clone, PartialEq, WipType)] #[derive(Debug, Clone, PartialEq, WipType)]
enum PublishOutcome { enum PublishOutcome {
/// Work has not completed and carries no payload.
Pending,
/// Application success. /// Application success.
Published(Receipt), Published(Receipt),
/// Domain rejection, not a host failure. /// Domain rejection, not a host failure.
Rejected(String), Rejected(String),
} }
fn field<'a>(record: &'a BTreeMap<String, Value>, name: &str) -> &'a Value {
record
.get(name)
.unwrap_or_else(|| panic!("missing `{name}`"))
}
#[test] #[test]
fn supported_values_round_trip() { fn primitive_unit_record_list_entry_and_json_values_round_trip() {
assert_eq!(().encode().unwrap(), Value::Unit);
assert_eq!(<()>::decode(Value::Unit).unwrap(), ());
assert_eq!(<()>::type_expr(), TypeExpr::Unit);
let request = Request { let request = Request {
title: "hello".into(), title: "hello".into(),
priority: None, priority: None,
related: vec![Entry { related: vec![Entry::new("/articles/1").unwrap()],
key: "/articles/1".into(),
value: true,
}],
body: Bytes(vec![1, 2, 3]), body: Bytes(vec![1, 2, 3]),
metadata: JsonValue::Object(vec![JsonMember { metadata: JsonValue::Object(vec![JsonMember {
name: "source".into(), name: "source".into(),
value: JsonValue::String("test".into()), value: JsonValue::Array(vec![JsonValue::String("test".into()), JsonValue::Null]),
}]), }]),
}; };
let encoded = request.clone().encode().unwrap(); let encoded = request.clone().encode().unwrap();
let Value::Record(fields) = &encoded else { let Value::Record(fields) = &encoded else {
panic!("record derive must encode a protocol record") panic!("record derive must encode a raw protocol record")
}; };
assert!(fields.iter().all(|field| field.name != "priority")); assert!(!fields.contains_key("priority"));
assert_eq!(
field(fields, "related"),
&Value::List(vec![Value::String("/articles/1".into())])
);
assert_eq!(
field(fields, "metadata"),
&Value::Record(BTreeMap::from([(
"source".into(),
Value::List(vec![Value::String("test".into()), Value::Unit]),
)]))
);
assert_eq!(Request::decode(encoded).unwrap(), request); assert_eq!(Request::decode(encoded).unwrap(), request);
let number = 42.5_f64; let number = 42.5_f64;
@@ -77,92 +96,136 @@ fn supported_values_round_trip() {
f32::decode(narrow_number.encode().unwrap()).unwrap(), f32::decode(narrow_number.encode().unwrap()).unwrap(),
narrow_number narrow_number
); );
for color in [Color::Red, Color::Blue] {
let encoded = color.clone().encode().unwrap();
let decoded = Color::decode(encoded).unwrap();
assert_eq!(decoded, color);
} }
#[test]
fn enums_are_strings_and_unions_are_discriminated_records() {
for color in [Color::Red, Color::Blue] {
let encoded = color.clone().encode().unwrap();
assert!(matches!(encoded, Value::String(_)));
assert_eq!(Color::decode(encoded).unwrap(), color);
}
let pending = PublishOutcome::Pending.encode().unwrap();
assert_eq!(
pending,
Value::Record(BTreeMap::from([(
UNION_CASE_FIELD.into(),
Value::String("Pending".into()),
)]))
);
let published = PublishOutcome::Published(Receipt { number: 7 })
.encode()
.unwrap();
let Value::Record(fields) = &published else {
panic!("union must encode as a record")
};
assert_eq!(
fields.get(UNION_CASE_FIELD),
Some(&Value::String("Published".into()))
);
assert!(matches!(
fields.get(UNION_VALUE_FIELD),
Some(Value::Record(_))
));
for outcome in [ for outcome in [
PublishOutcome::Pending,
PublishOutcome::Published(Receipt { number: 7 }), PublishOutcome::Published(Receipt { number: 7 }),
PublishOutcome::Rejected("policy".into()), PublishOutcome::Rejected("policy".into()),
] { ] {
let encoded = outcome.clone().encode().unwrap(); let encoded = outcome.clone().encode().unwrap();
let decoded = PublishOutcome::decode(encoded).unwrap(); assert_eq!(PublishOutcome::decode(encoded).unwrap(), outcome);
assert_eq!(decoded, outcome);
} }
} }
#[test] #[test]
fn derived_declarations_include_docs_optionality_and_dependencies() { fn derived_declarations_use_canonical_named_type_expressions() {
let mut declarations = Vec::new(); let mut declarations = Vec::new();
Request::collect_declarations(&mut declarations); Request::collect_declarations(&mut declarations);
Color::collect_declarations(&mut declarations); Color::collect_declarations(&mut declarations);
PublishOutcome::collect_declarations(&mut declarations); PublishOutcome::collect_declarations(&mut declarations);
let TypeDeclaration::Record(RecordDeclaration { let request = declarations
documentation, .iter()
fields, .find(|declaration| declaration.name == "Request")
.. .unwrap();
}) = &declarations[0] assert_eq!(
else { request.documentation.as_ref().unwrap().summary,
"A request record."
);
let TypeExpr::Record { fields } = &request.definition else {
panic!("Request must be a record declaration") panic!("Request must be a record declaration")
}; };
assert_eq!(documentation.as_ref().unwrap().summary, "A request record.");
assert_eq!( assert_eq!(
fields[0].documentation.as_ref().unwrap().summary, fields[0].documentation.as_ref().unwrap().summary,
"Required title." "Required title."
); );
assert!(!fields[0].optional); assert!(fields[0].required);
assert!(fields[1].optional); assert!(!fields[1].required);
assert_eq!(fields[1].ty, TypeExpr::Integer); assert_eq!(fields[1].r#type, TypeExpr::Integer);
assert_eq!( assert_eq!(
fields[2].ty, fields[2].r#type,
TypeExpr::List(Box::new(TypeExpr::Entry { TypeExpr::List {
key: Box::new(TypeExpr::String), items: Box::new(TypeExpr::Entry),
value: Box::new(TypeExpr::Boolean), }
}))
); );
let color = declarations let color = declarations
.iter() .iter()
.find(|declaration| declaration.name() == "Color") .find(|declaration| declaration.name == "Color")
.unwrap(); .unwrap();
let TypeDeclaration::Enum(EnumDeclaration { variants, .. }) = color else { let TypeExpr::Enum { cases } = &color.definition else {
panic!("Color must be an enum declaration") panic!("Color must be an enum declaration")
}; };
assert_eq!( assert_eq!(
variants[0].documentation.as_ref().unwrap().summary, cases[0].documentation.as_ref().unwrap().summary,
"Warm red." "Warm red."
); );
let outcome = declarations let outcome = declarations
.iter() .iter()
.find(|declaration| declaration.name() == "PublishOutcome") .find(|declaration| declaration.name == "PublishOutcome")
.unwrap(); .unwrap();
let TypeDeclaration::Union(UnionDeclaration { variants, .. }) = outcome else { let TypeExpr::Union { cases } = &outcome.definition else {
panic!("PublishOutcome must be a union declaration") panic!("PublishOutcome must be a union declaration")
}; };
assert_eq!(variants[0].ty, TypeExpr::Named("Receipt".into())); assert_eq!(cases[0].payload, None);
assert_eq!(
cases[1].payload,
Some(TypeExpr::Named {
name: "Receipt".into(),
})
);
assert!( assert!(
declarations declarations
.iter() .iter()
.any(|declaration| declaration.name() == "Receipt") .any(|declaration| declaration.name == "Receipt")
); );
} }
#[test] #[test]
fn codecs_reject_invalid_boundaries() { fn codecs_reject_invalid_boundaries_and_json_bytes() {
assert!(i64::MAX.encode().is_err()); assert!(i64::MAX.encode().is_err());
assert!(f64::NAN.encode().is_err()); assert!(f64::NAN.encode().is_err());
assert!(Request::decode(Value::Record(vec![])).is_err()); assert!(Request::decode(Value::Record(BTreeMap::new())).is_err());
assert!( assert!(
Request::decode(Value::Record(vec![NamedValue { Request::decode(Value::Record(BTreeMap::from([(
name: "unknown".into(), "unknown".into(),
value: Value::String("value".into()), Value::String("value".into()),
}])) )])))
.is_err()
);
assert!(Color::decode(Value::String("Green".into())).is_err());
assert!(Entry::new("articles/1").is_err());
assert!(Entry::decode(Value::String("/articles/../1".into())).is_err());
assert!(JsonValue::decode(Value::Bytes(vec![1])).is_err());
assert!(
JsonValue::decode(Value::Record(BTreeMap::from([(
"nested".into(),
Value::List(vec![Value::Bytes(vec![1])]),
)])))
.is_err() .is_err()
); );
assert!(Color::decode(Value::Enum("Green".into())).is_err());
} }
+47 -51
View File
@@ -1,4 +1,6 @@
use wip_interface::wip_protocol::{NamedValue, TypeDeclaration, TypeExpr, Value}; use std::collections::BTreeMap;
use wip_interface::wip_protocol::{INTERFACE_FORMAT_V1, TypeExpr, Value};
use wip_interface::{DispatchError, HostError, OperationResult, WipType, interface}; use wip_interface::{DispatchError, HostError, OperationResult, WipType, interface};
/// Publication request. /// Publication request.
@@ -36,7 +38,7 @@ pub trait Catalog {
note: Option<String>, note: Option<String>,
) -> OperationResult<PublishOutcome>; ) -> OperationResult<PublishOutcome>;
/// Checks connectivity without returning an application value. /// Checks connectivity without returning application data.
fn ping(&self) -> OperationResult<()>; fn ping(&self) -> OperationResult<()>;
/// Demonstrates a host/runtime failure. /// Demonstrates a host/runtime failure.
@@ -88,36 +90,29 @@ impl Catalog for RejectingCatalog {
} }
} }
fn named(name: &str, value: Value) -> NamedValue { fn publish_arguments(with_note: bool) -> BTreeMap<String, Value> {
NamedValue { let mut arguments = BTreeMap::from([(
name: name.into(), "request".into(),
value,
}
}
fn publish_arguments(with_note: bool) -> Vec<NamedValue> {
let mut arguments = vec![named(
"request",
PublishRequest { PublishRequest {
title: "example".into(), title: "example".into(),
} }
.encode() .encode()
.unwrap(), .unwrap(),
)]; )]);
if with_note { if with_note {
arguments.push(named("note", Value::String("audit".into()))); arguments.insert("note".into(), Value::String("audit".into()));
} }
arguments arguments
} }
#[test] #[test]
fn descriptor_is_generated_only_from_the_trait_declaration() { fn descriptor_is_canonical_and_generated_only_from_the_trait_declaration() {
let first = CatalogDefinition::descriptor(); let first = CatalogDefinition::descriptor();
let second = CatalogDefinition::descriptor(); let second = CatalogDefinition::descriptor();
assert_eq!(first, second); assert_eq!(first, second);
first.validate().unwrap(); first.validate().unwrap();
assert_eq!(first.name, "Catalog"); assert_eq!(first.format, INTERFACE_FORMAT_V1);
assert_eq!( assert_eq!(
first.documentation.as_ref().unwrap().summary, first.documentation.as_ref().unwrap().summary,
"Catalog editing capability." "Catalog editing capability."
@@ -139,81 +134,82 @@ fn descriptor_is_generated_only_from_the_trait_declaration() {
.summary, .summary,
"Item to publish." "Item to publish."
); );
assert!(!publish.parameters[0].optional); assert!(publish.parameters[0].required);
assert!(publish.parameters[1].optional); assert!(!publish.parameters[1].required);
assert_eq!( assert_eq!(
publish publish.returns.documentation.as_ref().unwrap().summary,
.returns
.as_ref()
.unwrap()
.documentation
.as_ref()
.unwrap()
.summary,
"The application publication outcome." "The application publication outcome."
); );
assert_eq!( assert_eq!(
publish.returns.as_ref().unwrap().ty, publish.returns.r#type,
TypeExpr::Named("PublishOutcome".into()) TypeExpr::Named {
name: "PublishOutcome".into(),
}
); );
assert!(first.operations[1].returns.is_none()); assert_eq!(first.operations[1].returns.r#type, TypeExpr::Unit);
assert!( assert!(
first first
.declarations .types
.iter() .iter()
.any(|declaration| matches!(declaration, TypeDeclaration::Record(_))) .any(|declaration| matches!(declaration.definition, TypeExpr::Record { .. }))
); );
assert!( assert!(
first first
.declarations .types
.iter() .iter()
.any(|declaration| matches!(declaration, TypeDeclaration::Union(_))) .any(|declaration| matches!(declaration.definition, TypeExpr::Union { .. }))
); );
// Both implementation types satisfy the same generated dispatch boundary; // Both implementation types satisfy one immutable generated definition;
// neither can affect the descriptor shape. // implementation selection cannot contribute fields to the descriptor.
fn accepts_definition<T: Catalog>(_: &T) {} fn accepts_definition<T: Catalog>(_: &T) {}
accepts_definition(&AcceptingCatalog); accepts_definition(&AcceptingCatalog);
accepts_definition(&RejectingCatalog); accepts_definition(&RejectingCatalog);
assert_eq!(CatalogDefinition::descriptor(), first);
} }
#[test] #[test]
fn generated_argument_decoder_and_result_encoder_round_trip() { fn generated_argument_decoder_and_result_encoder_use_raw_values() {
let decoded = CatalogPublishArguments::decode(publish_arguments(false)).unwrap(); let decoded = CatalogPublishArguments::decode(publish_arguments(false)).unwrap();
assert_eq!(decoded.request.title, "example"); assert_eq!(decoded.request.title, "example");
assert_eq!(decoded.note, None); assert_eq!(decoded.note, None);
let encoded = CatalogPublishArguments::encode_result(PublishOutcome::Rejected("domain".into())) let encoded =
.unwrap() CatalogPublishArguments::encode_result(PublishOutcome::Rejected("domain".into())).unwrap();
.unwrap();
assert_eq!( assert_eq!(
PublishOutcome::decode(encoded).unwrap(), PublishOutcome::decode(encoded).unwrap(),
PublishOutcome::Rejected("domain".into()) PublishOutcome::Rejected("domain".into())
); );
assert_eq!(
CatalogPingArguments::encode_result(()).unwrap(),
Value::Unit
);
} }
#[test] #[test]
fn typed_dispatch_validates_decodes_invokes_encodes_and_validates() { fn typed_dispatch_validates_decodes_invokes_encodes_and_validates() {
let value = CatalogDefinition::dispatch(&AcceptingCatalog, "publish", publish_arguments(true)) let value =
.unwrap() CatalogDefinition::dispatch(&AcceptingCatalog, "publish", publish_arguments(true)).unwrap();
.unwrap();
assert_eq!( assert_eq!(
PublishOutcome::decode(value).unwrap(), PublishOutcome::decode(value).unwrap(),
PublishOutcome::Published(true) PublishOutcome::Published(true)
); );
assert_eq!( assert_eq!(
CatalogDefinition::dispatch(&AcceptingCatalog, "ping", vec![]).unwrap(), CatalogDefinition::dispatch(&AcceptingCatalog, "ping", BTreeMap::new()).unwrap(),
None Value::Unit
); );
let invalid = CatalogDefinition::dispatch(&AcceptingCatalog, "publish", vec![]).unwrap_err(); let invalid =
CatalogDefinition::dispatch(&AcceptingCatalog, "publish", BTreeMap::new()).unwrap_err();
assert!(matches!(invalid, DispatchError::InvalidArguments(_))); assert!(matches!(invalid, DispatchError::InvalidArguments(_)));
let host = CatalogDefinition::dispatch(&AcceptingCatalog, "host_value", vec![]).unwrap_err(); let host =
CatalogDefinition::dispatch(&AcceptingCatalog, "host_value", BTreeMap::new()).unwrap_err();
assert!(matches!(host, DispatchError::Host(error) if error.message() == "backend unavailable")); assert!(matches!(host, DispatchError::Host(error) if error.message() == "backend unavailable"));
let unknown = CatalogDefinition::dispatch(&AcceptingCatalog, "missing", vec![]).unwrap_err(); let unknown =
CatalogDefinition::dispatch(&AcceptingCatalog, "missing", BTreeMap::new()).unwrap_err();
assert!(matches!(unknown, DispatchError::UnknownOperation(name) if name == "missing")); assert!(matches!(unknown, DispatchError::UnknownOperation(name) if name == "missing"));
} }
@@ -245,11 +241,11 @@ fn distinct_operation_names_always_generate_distinct_public_items() {
assert_eq!(descriptor.operations[0].name, "item"); assert_eq!(descriptor.operations[0].name, "item");
assert_eq!(descriptor.operations[1].name, "item_"); assert_eq!(descriptor.operations[1].name, "item_");
let first = DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item", vec![]) let first =
.unwrap() DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item", BTreeMap::new())
.unwrap(); .unwrap();
let second = DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item_", vec![]) let second =
.unwrap() DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item_", BTreeMap::new())
.unwrap(); .unwrap();
assert_eq!(first, Value::String("item".into())); assert_eq!(first, Value::String("item".into()));
assert_eq!(second, Value::String("item_".into())); assert_eq!(second, Value::String("item_".into()));
+2 -2
View File
@@ -1,9 +1,9 @@
use wip_interface::WipType; use wip_interface::WipType;
#[derive(WipType)] #[derive(WipType)]
enum MixedEnum { enum UnsupportedEnum {
Empty, Empty,
Payload(String), Payload { value: String },
} }
fn main() {} fn main() {}
@@ -1,5 +1,5 @@
error: a `WipType` enum must contain either only unit variants or only single-field tuple variants; mixed and named-field variants are unsupported error: a `WipType` enum must contain unit variants and/or single-field tuple variants; named-field and multi-field variants are unsupported
--> tests/ui/mixed_enum.rs:4:1 --> tests/ui/mixed_enum.rs:6:5
| |
4 | enum MixedEnum { 6 | Payload { value: String },
| ^^^^ | ^^^^^^^
@@ -4,16 +4,11 @@ error[E0277]: the trait bound `(std::string::String, std::string::String): WipTy
5 | fn load(&self, pair: (String, String)) -> OperationResult<String>; 5 | fn load(&self, pair: (String, String)) -> OperationResult<String>;
| ^^^^^^^^^^^^^^^^ the trait `WipType` is not implemented for `(std::string::String, std::string::String)` | ^^^^^^^^^^^^^^^^ the trait `WipType` is not implemented for `(std::string::String, std::string::String)`
| |
= help: the following other types implement trait `WipType`: help: the trait `WipType` is implemented for `()`
JsonValue --> src/lib.rs
Vec<T> |
bool | impl WipType for () {
f32 | ^^^^^^^^^^^^^^^^^^^
f64
i128
i16
i32
and $N others
error[E0277]: the trait bound `(std::string::String, std::string::String): WipType` is not satisfied error[E0277]: the trait bound `(std::string::String, std::string::String): WipType` is not satisfied
--> tests/ui/unsupported_type.rs:5:26 --> tests/ui/unsupported_type.rs:5:26
@@ -24,16 +19,11 @@ error[E0277]: the trait bound `(std::string::String, std::string::String): WipTy
5 | fn load(&self, pair: (String, String)) -> OperationResult<String>; 5 | fn load(&self, pair: (String, String)) -> OperationResult<String>;
| ^^^^^^^^^^^^^^^^ the trait `WipType` is not implemented for `(std::string::String, std::string::String)` | ^^^^^^^^^^^^^^^^ the trait `WipType` is not implemented for `(std::string::String, std::string::String)`
| |
= help: the following other types implement trait `WipType`: help: the trait `WipType` is implemented for `()`
JsonValue --> src/lib.rs
Vec<T> |
bool | impl WipType for () {
f32 | ^^^^^^^^^^^^^^^^^^^
f64
i128
i16
i32
and $N others
note: required by a bound in `wip_interface::__private::RecordDecoder::required` note: required by a bound in `wip_interface::__private::RecordDecoder::required`
--> src/lib.rs --> src/lib.rs
| |
+115 -77
View File
@@ -8,79 +8,65 @@ pub enum NameNamespace {
TypeDeclaration, TypeDeclaration,
/// Operations in an interface descriptor. /// Operations in an interface descriptor.
Operation, Operation,
/// Fields in a record declaration. /// Fields in an inline or named record type.
Field, Field,
/// Parameters in an operation declaration. /// Parameters in an operation declaration.
Parameter, Parameter,
/// Variants in an enum declaration. /// Cases in an enum type.
EnumVariant, EnumCase,
/// Variants in a union declaration. /// Cases in a union type.
UnionVariant, UnionCase,
/// Fields supplied in a record value. /// Child object names beneath one tree node.
RecordValueField, ChildObject,
/// 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. /// Structural kind of a schema-neutral logical value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValueKind { pub enum ValueKind {
/// String value. /// Unit value.
String, Unit,
/// Boolean value. /// Boolean value.
Boolean, Boolean,
/// Integer value. /// Integer value.
Integer, Integer,
/// Floating-point number value. /// Floating-point number value.
Number, Number,
/// String value.
String,
/// Byte sequence. /// Byte sequence.
Bytes, Bytes,
/// List value.
List,
/// Key/value entry.
Entry,
/// Record value. /// Record value.
Record, Record,
/// Enum value. /// List value.
Enum, List,
/// Tagged-union value.
Union,
/// Logical JSON value.
Json,
} }
/// A typed segment identifying a declaration or value location. /// A typed segment identifying a declaration or value location.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathSegment { pub enum PathSegment {
/// Interface descriptor. /// Descriptor root.
Interface(String), Descriptor,
/// Named type declaration. /// Named type declaration.
Declaration(String), Declaration(String),
/// Operation declaration or call. /// Operation declaration or call.
Operation(String), Operation(String),
/// Record field declaration or value. /// Record field declaration or value.
Field(String), Field(String),
/// Operation parameter declaration or argument. /// Operation parameter or argument.
Parameter(String), Parameter(String),
/// Enum or union variant. /// Enum or union case.
Variant(String), Case(String),
/// List or JSON array index. /// List index.
Index(usize), Index(usize),
/// Entry key.
EntryKey,
/// Entry value.
EntryValue,
/// Operation return value. /// Operation return value.
Return, Return,
/// JSON object member. /// Object at a Worldspace path.
JsonMember(String), Object(String),
/// Child object in a tree response.
Child(String),
} }
/// Machine-readable reason why protocol validation failed. /// Machine-readable reason why logical protocol validation failed.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationErrorKind { pub enum ValidationErrorKind {
/// A name occurred more than once in one namespace. /// A name occurred more than once in one namespace.
@@ -117,11 +103,15 @@ pub enum ValidationErrorKind {
/// Missing field name. /// Missing field name.
name: String, name: String,
}, },
/// An enum or union value selected an undeclared variant. /// An enum or union selected an undeclared case.
UnknownVariant { UnknownCase {
/// Unknown variant name. /// Unknown case name.
name: String, name: String,
}, },
/// A payload-bearing union case omitted `value`.
MissingUnionPayload,
/// A payload-free union case supplied `value`.
UnexpectedUnionPayload,
/// An operation call contains an undeclared argument. /// An operation call contains an undeclared argument.
UnknownParameter { UnknownParameter {
/// Unknown parameter name. /// Unknown parameter name.
@@ -132,44 +122,66 @@ pub enum ValidationErrorKind {
/// Missing parameter name. /// Missing parameter name.
name: String, name: String,
}, },
/// An integer is outside the JavaScript-lossless range. /// An operation is not declared by the descriptor.
UnknownOperation {
/// Unknown operation name.
name: String,
},
/// An integer is outside the interoperable safe range.
IntegerOutOfRange { IntegerOutOfRange {
/// Rejected integer. /// Rejected integer.
value: i64, value: i64,
}, },
/// A numeric value is NaN or infinite. /// A numeric value is NaN or infinite.
NonFiniteNumber, NonFiniteNumber,
/// A JSON-interpreted value tree contains protocol bytes.
BytesInJson,
/// An object name is not a valid root or non-root path segment.
InvalidObjectName {
/// Rejected object name.
name: String,
},
/// An object's ordered interface set contains a duplicate. /// An object's ordered interface set contains a duplicate.
DuplicateInterface { DuplicateInterface {
/// Duplicate interface name. /// Duplicate opaque interface reference.
name: String, reference: String,
}, },
/// An observation omits a descriptor named by its object. /// A string is not a canonical absolute Worldspace path.
MissingInterfaceDescriptor { InvalidPath {
/// Missing interface name. /// Rejected path.
name: String, path: String,
}, },
/// An observation contains a descriptor not named by its object. /// An object name does not match the path where it was observed.
UnexpectedInterfaceDescriptor { ObjectNameMismatch {
/// Unexpected interface name. /// Name required by the observation path.
name: String,
},
/// A call targets an interface other than the descriptor being used.
InterfaceMismatch {
/// Descriptor interface name.
expected: String, expected: String,
/// Call target interface name. /// Name supplied by the object.
actual: String, actual: String,
}, },
/// An operation is not declared by the interface. /// Equivalent ref/validator observations disagree on object representation.
UnknownOperation { InconsistentObjectObservation {
/// Unknown operation name. /// Shared opaque object reference.
name: String, reference: String,
},
/// A `fetch_tree` request used a zero limit.
ZeroTreeLimit,
/// A tree response exceeds the requested maximum depth.
TreeDepthExceeded {
/// Requested maximum depth.
maximum: u32,
},
/// A tree response exceeds the requested maximum node count.
TreeLimitExceeded {
/// Requested maximum node count.
limit: u32,
},
/// A response refers to a different interface than its request.
InterfaceResponseMismatch {
/// Requested opaque reference.
expected: String,
/// Returned opaque reference.
actual: String,
}, },
/// A no-return operation produced a value.
UnexpectedReturnValue,
/// An operation with a return declaration produced no value.
MissingReturnValue,
} }
/// A validation failure with a typed path to the failing location. /// A validation failure with a typed path to the failing location.
@@ -211,30 +223,56 @@ impl Display for ValidationError {
impl Error for ValidationError {} impl Error for ValidationError {}
/// Stable category of an error returned by a protocol host runtime. /// Stable protocol failure code returned by a host or produced by a client.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtocolErrorCategory { pub enum ProtocolErrorCode {
/// The request is structurally invalid. /// The request is structurally invalid.
InvalidRequest, InvalidRequest,
/// The target object is not published. /// The target object is not published.
ObjectNotFound, NotFound,
/// The target interface is not implemented by the object. /// The interface reference is not currently published.
InterfaceNotFound, InterfaceNotFound,
/// The target operation is not declared by the interface. /// The selected interface is not on the current object.
InterfaceMismatch,
/// The selected operation is not declared by the current interface.
OperationNotFound, OperationNotFound,
/// Supplied operation arguments are invalid. /// Supplied operation arguments are invalid.
InvalidArguments, InvalidArguments,
/// The implementation rejected the call for a domain reason. /// A host result does not satisfy the declaration.
OperationFailed, InvalidResult,
/// The operation requires an object validator and none was supplied.
ValidatorRequired,
/// The supplied object validator does not match current state.
ValidatorMismatch,
/// A mutable interface requires its validator and none was supplied.
InterfaceValidatorRequired,
/// The supplied interface validator does not match the descriptor.
InterfaceValidatorMismatch,
/// A published operation is not permitted for the caller.
PermissionDenied,
/// A bounded protocol resource limit was exceeded.
ResourceLimitExceeded,
/// The host encountered an internal failure. /// The host encountered an internal failure.
Internal, Internal,
} }
/// Transport-independent protocol failure returned by a host runtime. /// Transport-independent protocol failure.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProtocolError { pub struct ProtocolError {
/// Stable programmatic category. /// Stable programmatic code. Consumers must branch on this field.
pub category: ProtocolErrorCategory, pub code: ProtocolErrorCode,
/// Human-readable diagnostic. Consumers must not branch on this text. /// 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 {}
+50 -13
View File
@@ -1,9 +1,32 @@
//! Transport-independent logical types for the Web Interface Protocol (WIP). //! Canonical transport-independent logical types for the Web Interface Protocol.
//! //!
//! This crate deliberately models interface declarations, published objects, //! This crate follows the Core Model and `3-protocol.md` without assigning HTTP
//! operation calls, and operation results without assigning them HTTP routes, //! routes, methods, headers, URL encodings, or JSON envelopes. The correspondence
//! methods, headers, or JSON envelopes. A transport can encode these values, //! between canonical logical concepts and this crate is direct:
//! but the encoding is outside this crate's contract. //!
//! | Canonical protocol concept | Rust API |
//! | --- | --- |
//! | Object projection and ordered interface set | [`Object`] |
//! | Optional object identity / state precondition | `Object::ref` / [`Object::validator`] |
//! | Interface Descriptor | [`InterfaceDescriptor`] |
//! | Named declaration and all schema expressions | [`TypeDeclaration`] / [`TypeExpr`] |
//! | Schema-neutral primitive/composite value | [`Value`] |
//! | `fetch` | [`FetchRequest`] / [`FetchResponse`] |
//! | `fetch_tree` | [`FetchTreeRequest`] / [`FetchTreeResponse`] |
//! | `fetch_interface` | [`FetchInterfaceRequest`] / [`FetchInterfaceResponse`] |
//! | Object and interface call preconditions | [`Target`] / [`InterfaceTarget`] |
//! | `call_operation` | [`CallOperationRequest`] / [`CallOperationResponse`] |
//! | Stable failure taxonomy | [`ProtocolErrorCode`] / [`ProtocolError`] |
//!
//! Paths, object refs, interface refs, and validators remain separate concepts.
//! They are represented by ordinary protocol strings/bytes in the corresponding
//! fields. In particular, object refs are optional, operation targeting uses a
//! path, and this crate defines no interaction that fetches an object by ref.
//!
//! [`Value`] contains only the canonical raw value kinds. A descriptor interprets
//! enum values as strings, entries as canonical-path strings, and unions as
//! records with a `$case` string plus an optional `value` payload. JSON uses the
//! same value tree (`Unit` is JSON null) and rejects bytes.
#![deny(missing_docs)] #![deny(missing_docs)]
@@ -12,19 +35,33 @@ mod model;
mod validation; mod validation;
pub use error::{ pub use error::{
NameNamespace, PathSegment, ProtocolError, ProtocolErrorCategory, ValidationError, NameNamespace, PathSegment, ProtocolError, ProtocolErrorCode, ValidationError,
ValidationErrorKind, ValueKind, ValidationErrorKind, ValueKind,
}; };
pub use model::{ pub use model::{
Documentation, EnumDeclaration, EnumVariantDeclaration, FieldDeclaration, InterfaceDescriptor, CallOperationRequest, CallOperationResponse, Documentation, EnumCase, FetchInterfaceRequest,
InterfaceObservation, JsonMember, JsonValue, NamedValue, Object, ObjectId, OperationCall, FetchInterfaceResponse, FetchRequest, FetchResponse, FetchTreeRequest, FetchTreeResponse,
OperationDeclaration, OperationOutcome, OperationResult, OperationTarget, ParameterDeclaration, FieldDeclaration, InterfaceDescriptor, InterfaceTarget, Object, ObjectTreeNode,
RecordDeclaration, ReturnDeclaration, TypeDeclaration, TypeExpr, UnionDeclaration, OperationDeclaration, ParameterDeclaration, ProtocolResult, ReturnDeclaration, Target,
UnionVariantDeclaration, Value, TypeDeclaration, TypeExpr, UnionCase, Value,
}; };
/// Largest integer that is exactly interoperable with a JavaScript `Number`. /// Validates a canonical absolute Worldspace path.
pub fn validate_path(path: &str) -> Result<(), ValidationError> {
validation::validate_path(path)
}
/// Canonical format identifier for the first Interface Descriptor format.
pub const INTERFACE_FORMAT_V1: &str = "wip-interface/1";
/// Discriminator field used by a union's raw record value.
pub const UNION_CASE_FIELD: &str = "$case";
/// Optional payload field used by a union's raw record value.
pub const UNION_VALUE_FIELD: &str = "value";
/// Largest integer exactly interoperable with a JavaScript `Number`.
pub const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; pub const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
/// Smallest integer that is exactly interoperable with a JavaScript `Number`. /// Smallest integer exactly interoperable with a JavaScript `Number`.
pub const MIN_SAFE_INTEGER: i64 = -MAX_SAFE_INTEGER; pub const MIN_SAFE_INTEGER: i64 = -MAX_SAFE_INTEGER;
+292 -251
View File
@@ -1,6 +1,8 @@
use std::collections::BTreeMap;
use crate::{ProtocolError, ValidationError}; use crate::{ProtocolError, ValidationError};
/// Human-readable documentation attached to a protocol declaration. /// Human-readable plain-text documentation attached to a protocol declaration.
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Documentation { pub struct Documentation {
/// A short, self-contained description. /// A short, self-contained description.
@@ -9,242 +11,194 @@ pub struct Documentation {
pub details: Option<String>, pub details: Option<String>,
} }
/// Stable logical identity of a published object. /// The protocol projection currently published at one Worldspace path.
#[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 /// `name` is a path segment, `interfaces` is an ordered set of opaque interface
/// publication to reject duplicate interface names. /// references, `r#ref` is optional object identity, and `validator` is an
/// independent optional observation precondition. In particular, neither the
/// path nor `r#ref` is a mandatory object identifier.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Object { pub struct Object {
/// Logical object identity. /// Path-segment name. The root object's name is empty.
pub id: ObjectId, pub name: String,
/// Interface names in publication order. /// Short public description of the object.
pub description: Option<String>,
/// Opaque interface references in host publication order.
pub interfaces: Vec<String>, pub interfaces: Vec<String>,
} /// Optional opaque identity within one client-facing Worldspace.
pub r#ref: Option<String>,
/// A complete logical observation of an object and its interface descriptors. /// Optional opaque validator for the object's observed state.
#[derive(Debug, Clone, PartialEq)] pub validator: Option<Vec<u8>>,
pub struct InterfaceObservation {
/// Observed object.
pub object: Object,
/// Descriptors corresponding to `object.interfaces`.
pub descriptors: Vec<InterfaceDescriptor>,
} }
/// Transport-independent description of one interface. /// Transport-independent description of one interface.
#[derive(Debug, Clone, PartialEq)] ///
/// Interface identity is supplied by interaction fields such as
/// [`FetchInterfaceResponse::interface`], not embedded in the descriptor.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterfaceDescriptor { pub struct InterfaceDescriptor {
/// Interface name. /// Descriptor format version understood by clients and hosts.
pub name: String, pub format: String,
/// Optional interface documentation. /// Optional interface documentation.
pub documentation: Option<Documentation>, pub documentation: Option<Documentation>,
/// Named type declarations local to this descriptor. /// Named declarations local to this descriptor.
pub declarations: Vec<TypeDeclaration>, pub types: Vec<TypeDeclaration>,
/// Operations exposed by this interface. /// Operations exposed by this interface.
pub operations: Vec<OperationDeclaration>, pub operations: Vec<OperationDeclaration>,
} }
/// A named type declaration local to an interface descriptor. /// A descriptor-local named type declaration.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum TypeDeclaration { pub struct 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. /// Descriptor-local type name.
pub name: String, pub name: String,
/// Optional record documentation. /// Optional type documentation.
pub documentation: Option<Documentation>, pub documentation: Option<Documentation>,
/// Definition associated with `name`.
pub definition: TypeExpr,
}
/// A type expression used by declarations, fields, parameters, and returns.
///
/// [`TypeExpr::Named`] references are resolved only within the containing
/// descriptor. Recursive references, including recursion through lists,
/// records, and unions, are invalid.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TypeExpr {
/// A value carrying no data.
Unit,
/// A boolean.
Boolean,
/// A JavaScript-lossless signed integer.
Integer,
/// A finite IEEE-754 binary64 number.
Number,
/// A Unicode string.
String,
/// An opaque byte sequence.
Bytes,
/// A schema-opaque JSON-compatible value tree.
Json,
/// A canonical absolute Worldspace path represented by a string value.
Entry,
/// A descriptor-local named declaration.
Named {
/// Referenced declaration name.
name: String,
},
/// A record with named fields.
Record {
/// Fields in declaration order. /// Fields in declaration order.
pub fields: Vec<FieldDeclaration>, fields: Vec<FieldDeclaration>,
},
/// An ordered list.
List {
/// Type of every list item.
items: Box<TypeExpr>,
},
/// A closed set of payload-free cases represented by strings.
Enum {
/// Cases in declaration order.
cases: Vec<EnumCase>,
},
/// A discriminated union represented by a record.
Union {
/// Cases in declaration order.
cases: Vec<UnionCase>,
},
} }
/// Declaration of a record field. /// Declaration of a record field.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldDeclaration { pub struct FieldDeclaration {
/// Field name. /// Field name.
pub name: String, pub name: String,
/// Whether the field must be present.
pub required: bool,
/// Optional field documentation. /// Optional field documentation.
pub documentation: Option<Documentation>, pub documentation: Option<Documentation>,
/// Field value type. /// Field value type.
pub ty: TypeExpr, pub r#type: TypeExpr,
/// Whether the field may be omitted.
///
/// Optionality represents absence, not a `null` value.
pub optional: bool,
} }
/// Declaration of an enumeration. /// One payload-free enum case.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnumDeclaration { pub struct EnumCase {
/// Descriptor-local type name. /// Case name.
pub name: String, pub name: String,
/// Optional enum documentation. /// Optional case documentation.
pub documentation: Option<Documentation>, pub documentation: Option<Documentation>,
/// Allowed variants in declaration order.
pub variants: Vec<EnumVariantDeclaration>,
} }
/// Declaration of one enumeration variant. /// One union case with zero or one payload.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnumVariantDeclaration { pub struct UnionCase {
/// Variant name. /// Case name.
pub name: String, pub name: String,
/// Optional variant documentation. /// Optional case documentation.
pub documentation: Option<Documentation>, pub documentation: Option<Documentation>,
} /// Optional payload type.
pub payload: Option<TypeExpr>,
/// 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. /// Declaration of one interface operation.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct OperationDeclaration { pub struct OperationDeclaration {
/// Operation name, unique within its interface. /// Operation name, unique within its descriptor.
pub name: String, pub name: String,
/// Optional operation documentation. /// Optional operation documentation.
pub documentation: Option<Documentation>, pub documentation: Option<Documentation>,
/// Parameters in declaration order. /// Parameters in declaration order.
pub parameters: Vec<ParameterDeclaration>, pub parameters: Vec<ParameterDeclaration>,
/// Return declaration, or `None` for an operation with no return value. /// Return declaration. No-data operations return [`TypeExpr::Unit`].
pub returns: Option<ReturnDeclaration>, pub returns: ReturnDeclaration,
} }
/// Declaration of one operation parameter. /// Declaration of one operation parameter.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParameterDeclaration { pub struct ParameterDeclaration {
/// Parameter name. /// Parameter name.
pub name: String, pub name: String,
/// Whether the parameter must be present.
pub required: bool,
/// Optional parameter documentation. /// Optional parameter documentation.
pub documentation: Option<Documentation>, pub documentation: Option<Documentation>,
/// Parameter value type. /// Parameter value type.
pub ty: TypeExpr, pub r#type: TypeExpr,
/// Whether the parameter may be omitted.
///
/// Optionality represents absence, not a `null` value.
pub optional: bool,
} }
/// Declaration of an operation return value. /// Declaration of an operation return value.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReturnDeclaration { pub struct ReturnDeclaration {
/// Optional return-value documentation. /// Optional return-value documentation.
pub documentation: Option<Documentation>, pub documentation: Option<Documentation>,
/// Return value type. /// Return value type.
pub ty: TypeExpr, pub r#type: TypeExpr,
} }
/// A logical protocol value. /// A schema-neutral logical protocol value.
/// ///
/// There is no general `Null` variant: optional record fields and operation /// Enum cases, union cases, entries, and JSON are interpretations imposed by a
/// parameters are omitted. JSON null remains available inside [`Value::Json`]. /// descriptor. They are deliberately not variants of this enum. Record key
/// ordering is not protocol-significant.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum Value { pub enum Value {
/// A UTF-8 string. /// A value carrying no data; it also represents JSON null under `Json`.
String(String), Unit,
/// A boolean. /// A boolean.
Boolean(bool), Boolean(bool),
/// An integer, validated against the JavaScript safe integer range. /// A JavaScript-lossless integer.
Integer(i64), Integer(i64),
/// A number, validated to be finite. /// A finite IEEE-754 binary64 number.
Number(f64), Number(f64),
/// A Unicode string.
String(String),
/// An opaque byte sequence. /// An opaque byte sequence.
Bytes(Vec<u8>), Bytes(Vec<u8>),
/// String-keyed fields with order-insensitive semantics.
Record(BTreeMap<String, Value>),
/// An ordered sequence. /// An ordered sequence.
List(Vec<Value>), 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 { impl Value {
@@ -252,132 +206,219 @@ impl Value {
#[must_use] #[must_use]
pub fn kind(&self) -> crate::ValueKind { pub fn kind(&self) -> crate::ValueKind {
match self { match self {
Self::String(_) => crate::ValueKind::String, Self::Unit => crate::ValueKind::Unit,
Self::Boolean(_) => crate::ValueKind::Boolean, Self::Boolean(_) => crate::ValueKind::Boolean,
Self::Integer(_) => crate::ValueKind::Integer, Self::Integer(_) => crate::ValueKind::Integer,
Self::Number(_) => crate::ValueKind::Number, Self::Number(_) => crate::ValueKind::Number,
Self::String(_) => crate::ValueKind::String,
Self::Bytes(_) => crate::ValueKind::Bytes, Self::Bytes(_) => crate::ValueKind::Bytes,
Self::List(_) => crate::ValueKind::List,
Self::Entry { .. } => crate::ValueKind::Entry,
Self::Record(_) => crate::ValueKind::Record, Self::Record(_) => crate::ValueKind::Record,
Self::Enum(_) => crate::ValueKind::Enum, Self::List(_) => crate::ValueKind::List,
Self::Union { .. } => crate::ValueKind::Union,
Self::Json(_) => crate::ValueKind::Json,
} }
} }
} }
/// A named value used by records and operation arguments. /// Request for the object currently published at one path.
#[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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct OperationTarget { pub struct FetchRequest {
/// Target object. /// Canonical absolute Worldspace path.
pub object: ObjectId, pub path: String,
/// Target interface name. }
/// Successful `fetch` response.
pub type FetchResponse = Object;
/// Request for a bounded breadth-first object-tree observation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FetchTreeRequest {
/// Canonical absolute path of the tree root.
pub path: String,
/// Maximum depth, where the requested object has depth zero.
pub depth: u32,
/// Maximum total node count, including the root. Must be nonzero.
pub limit: u32,
}
/// One node in a tree observation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObjectTreeNode {
/// Object observed at this tree position.
pub object: Object,
/// Direct children in host-defined traversal order.
pub children: Vec<ObjectTreeNode>,
}
/// Successful `fetch_tree` response.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FetchTreeResponse {
/// Observed tree rooted at the requested path.
pub root: ObjectTreeNode,
/// Whether public indexable children were omitted by depth or limit.
pub truncated: bool,
}
/// Request for the current descriptor of one opaque interface reference.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FetchInterfaceRequest {
/// Opaque interface reference obtained from an object.
pub interface: String, pub interface: String,
/// Target operation name. }
/// Successful `fetch_interface` response.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FetchInterfaceResponse {
/// Opaque interface reference corresponding to the request.
pub interface: String,
/// Current validated descriptor.
pub descriptor: InterfaceDescriptor,
/// Optional opaque validator for this descriptor observation.
pub validator: Option<Vec<u8>>,
}
/// Object portion of an operation target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
/// Canonical absolute path of the target object.
pub path: String,
/// Optional validator copied from the object observation.
pub validator: Option<Vec<u8>>,
}
/// Interface portion of an operation target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterfaceTarget {
/// Opaque interface reference selected from the object's interface set.
pub reference: String,
/// Optional validator copied from the interface observation.
pub validator: Option<Vec<u8>>,
}
/// Transport-independent request to execute one operation.
#[derive(Debug, Clone, PartialEq)]
pub struct CallOperationRequest {
/// Path and optional object-state precondition.
pub target: Target,
/// Interface reference and its independent optional precondition.
pub interface: InterfaceTarget,
/// Operation name within the selected descriptor.
pub operation: String, pub operation: String,
/// Named arguments; map iteration order has no protocol meaning.
pub arguments: BTreeMap<String, Value>,
} }
/// A transport-independent operation call. /// Successful operation response.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct OperationCall { pub struct CallOperationResponse {
/// Logical call target. /// Application result validated against the operation return declaration.
pub target: OperationTarget, pub result: Value,
/// Named arguments. Optional arguments are represented by omission. /// Optional validator for the object state observed after execution.
pub arguments: Vec<NamedValue>, pub validator: Option<Vec<u8>>,
} }
/// Successful or failed logical outcome of an operation. /// Logical outcome of any core interaction.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum OperationOutcome { pub enum ProtocolResult<T> {
/// Operation returned. `None` represents a declared no-return operation. /// The interaction succeeded.
Returned(Option<Value>), Success(T),
/// Operation failed with a protocol-level error. /// The interaction failed at the protocol boundary.
Failed(ProtocolError), Failure(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 { impl Object {
/// Validates ordered-set interface semantics. /// Validates the object-independent name and ordered-interface-set rules.
pub fn validate(&self) -> Result<(), ValidationError> { pub fn validate(&self) -> Result<(), ValidationError> {
crate::validation::validate_object(self) crate::validation::validate_object(self)
} }
/// Validates this object as the response observed at `path`.
pub fn validate_at_path(&self, path: &str) -> Result<(), ValidationError> {
crate::validation::validate_object_at_path(self, path)
} }
impl InterfaceObservation { /// Validates equal representations for a shared ref and present validator.
/// Validates the object, descriptor structure, and their correspondence. pub fn validate_consistency_with(&self, other: &Self) -> Result<(), ValidationError> {
pub fn validate(&self) -> Result<(), ValidationError> { crate::validation::validate_object_consistency(self, other)
crate::validation::validate_observation(self)
} }
} }
impl InterfaceDescriptor { impl InterfaceDescriptor {
/// Validates declaration structure, references, and absence of recursion. /// Validates declaration structure, named references, and absence of cycles.
pub fn validate(&self) -> Result<(), ValidationError> { pub fn validate(&self) -> Result<(), ValidationError> {
crate::validation::validate_descriptor(self) crate::validation::validate_descriptor(self)
} }
/// Validates a value against a type expression in this descriptor. /// Validates a raw value against a type expression in this descriptor.
pub fn validate_value(&self, ty: &TypeExpr, value: &Value) -> Result<(), ValidationError> { pub fn validate_value(&self, r#type: &TypeExpr, value: &Value) -> Result<(), ValidationError> {
crate::validation::validate_root_value(self, ty, value) crate::validation::validate_root_value(self, r#type, value)
} }
/// Validates a call target and arguments against this descriptor. /// Validates named arguments for one declared operation.
pub fn validate_call(&self, call: &OperationCall) -> Result<(), ValidationError> { pub fn validate_arguments(
crate::validation::validate_call(self, call)
}
/// Validates an operation's returned value against this descriptor.
pub fn validate_return(
&self, &self,
operation: &str, operation: &str,
value: Option<&Value>, arguments: &BTreeMap<String, Value>,
) -> Result<(), ValidationError> { ) -> Result<(), ValidationError> {
crate::validation::validate_return(self, operation, value) crate::validation::validate_arguments(self, operation, arguments)
}
/// Validates one operation result against its return declaration.
pub fn validate_result(&self, operation: &str, result: &Value) -> Result<(), ValidationError> {
crate::validation::validate_result(self, operation, result)
}
/// Validates the path, operation, and arguments in a call request.
pub fn validate_call(&self, request: &CallOperationRequest) -> Result<(), ValidationError> {
crate::validation::validate_call(self, request)
}
}
impl FetchRequest {
/// Validates the request path.
pub fn validate(&self) -> Result<(), ValidationError> {
crate::validation::validate_path(&self.path)
}
/// Validates an object returned for this request.
pub fn validate_response(&self, response: &FetchResponse) -> Result<(), ValidationError> {
response.validate_at_path(&self.path)
}
}
impl FetchTreeRequest {
/// Validates the request path and nonzero node limit.
pub fn validate(&self) -> Result<(), ValidationError> {
crate::validation::validate_fetch_tree_request(self)
}
}
impl FetchTreeResponse {
/// Validates tree shape, names, depth, and node limit for `request`.
pub fn validate_for(&self, request: &FetchTreeRequest) -> Result<(), ValidationError> {
crate::validation::validate_fetch_tree_response(self, request)
}
}
impl FetchInterfaceResponse {
/// Validates request correspondence and descriptor semantics.
pub fn validate_for(&self, request: &FetchInterfaceRequest) -> Result<(), ValidationError> {
crate::validation::validate_fetch_interface_response(self, request)
}
}
impl CallOperationRequest {
/// Validates this call against the descriptor selected by `interface`.
pub fn validate_with(&self, descriptor: &InterfaceDescriptor) -> Result<(), ValidationError> {
descriptor.validate_call(self)
}
}
impl CallOperationResponse {
/// Validates this result against the named operation in `descriptor`.
pub fn validate_for(
&self,
descriptor: &InterfaceDescriptor,
operation: &str,
) -> Result<(), ValidationError> {
descriptor.validate_result(operation, &self.result)
} }
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+123 -390
View File
@@ -1,41 +1,25 @@
//! 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)]
use std::collections::BTreeMap;
use std::error::Error; use std::error::Error;
use std::io; use std::io;
use wip_host::{ use wip_host::{
CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget, PublishedObject, CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget, ObjectProvider,
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::{ use wip_protocol::ProtocolError;
InterfaceObservation, NamedValue, ObjectId, OperationOutcome, OperationResult, OperationTarget,
};
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";
@@ -49,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.
@@ -74,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>;
} }
@@ -90,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>;
} }
@@ -114,10 +80,10 @@ pub trait Diagnostics {
pub struct DemoReport { pub struct DemoReport {
/// Opaque interface references published by the object, in exposure order. /// Opaque interface references published by the object, in exposure order.
pub published_interfaces: Vec<String>, pub published_interfaces: Vec<String>,
/// Descriptor identity observed for the catalog interface. /// Descriptor format observed for the catalog interface.
pub catalog_descriptor: String, pub catalog_descriptor_format: String,
/// Descriptor identity observed for the audit interface. /// Descriptor format observed for the audit interface.
pub audit_descriptor: String, pub audit_descriptor_format: String,
/// Typed successful application result decoded from a validated `Value`. /// Typed successful application result decoded from a validated `Value`.
pub published: PublishOutcome, pub published: PublishOutcome,
/// Typed application-domain rejection decoded from a validated `Value`. /// Typed application-domain rejection decoded from a validated `Value`.
@@ -128,113 +94,82 @@ pub struct DemoReport {
pub audit_status: String, pub audit_status: String,
/// Stable failure kind produced by the deliberate host/backend failure. /// Stable failure kind produced by the deliberate host/backend failure.
pub host_failure: CallErrorKind, pub host_failure: CallErrorKind,
/// A protocol-level operation result built from the host failure. /// A protocol-level failure built from the host failure.
pub protocol_failure: OperationResult, pub protocol_failure: ProtocolError,
} }
/// 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"))?;
InterfaceObservation {
object: object.clone(),
descriptors: vec![
catalog_observation.descriptor.clone(),
audit_observation.descriptor.clone(),
],
}
.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",
vec![], BTreeMap::new(),
), ),
"catalog status",
)?; )?;
let audit_status = dispatch_string( let audit_status = dispatch_string(
&publication, &provider,
request(audit_observation.reference.clone(), None, "status", vec![]), request(audit.reference(), 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",
vec![], 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: {:?}",
@@ -242,20 +177,12 @@ pub fn run() -> Result<DemoReport, Box<dyn Error>> {
)) ))
.into()); .into());
} }
let protocol_error = host_error.protocol_error(); let protocol_failure = host_error.protocol_error();
let protocol_failure = OperationResult {
target: OperationTarget {
object: ObjectId(OBJECT_ID.into()),
interface: catalog_observation.reference.as_str().into(),
operation: "backend_status".into(),
},
outcome: OperationOutcome::Failed(protocol_error),
};
Ok(DemoReport { Ok(DemoReport {
published_interfaces: object.interfaces, published_interfaces: object.interfaces,
catalog_descriptor: catalog_observation.descriptor.name, catalog_descriptor_format: catalog_observation.descriptor.format,
audit_descriptor: audit_observation.descriptor.name, audit_descriptor_format: audit_observation.descriptor.format,
published, published,
rejected, rejected,
catalog_status, catalog_status,
@@ -266,56 +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",
vec![NamedValue { BTreeMap::from([("request".into(), encoded_request)]),
name: "request".into(),
value: encoded_request,
}],
))?; ))?;
let value = result Ok(PublishOutcome::decode(result.result)?)
.value
.ok_or_else(|| io::Error::other("publish returned no application value"))?;
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 Ok(String::decode(result.result)?)
.value
.ok_or_else(|| io::Error::other(format!("{context} returned no value")))?;
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: Vec<NamedValue>, 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,
@@ -331,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
} }
} }
@@ -381,247 +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::{ProtocolErrorCategory, Value};
/// A deliberately inconsistent custom codec used to prove result validation. #[test]
pub struct InvalidValue; fn unexposed_interfaces_are_membership_failures() {
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 mut registry = InterfaceRegistry::new();
let catalog = registry let catalog = registry.register(CatalogDefinition, None).unwrap();
.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))
.unwrap();
let audit = registry.register(AuditDefinition, None).unwrap(); let audit = registry.register(AuditDefinition, None).unwrap();
let diagnostics = registry.register(DiagnosticsDefinition, None).unwrap(); let diagnostics = registry.register(DiagnosticsDefinition, None).unwrap();
let publication = PublishedObject::new(ApplicationService::new()) let provider = ApplicationProvider { catalog, audit };
.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, category: ProtocolErrorCategory) { let error = provider
assert_eq!(error.kind(), kind); .call_operation(request(
assert_eq!(error.protocol_error().category, category); diagnostics.reference(),
}
#[test]
fn interface_membership_and_same_name_operations_are_qualified() {
let fixture = fixture();
let catalog = dispatch_string(
&fixture.publication,
request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
vec![],
),
"catalog status",
)
.unwrap();
let audit = dispatch_string(
&fixture.publication,
request(fixture.audit.clone(), None, "status", vec![]),
"audit status",
)
.unwrap();
assert_eq!(catalog, "catalog-ready");
assert_eq!(audit, "audit-ready");
let not_exposed = fixture
.publication
.dispatch(request(fixture.diagnostics, None, "diagnostics", vec![]))
.unwrap_err();
assert_failure(
not_exposed,
CallErrorKind::InterfaceMismatch,
ProtocolErrorCategory::InterfaceNotFound,
);
}
#[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",
vec![],
);
wrong_object.object = ObjectId("other-object".into());
assert_failure(
fixture.publication.dispatch(wrong_object).unwrap_err(),
CallErrorKind::ObjectNotFound,
ProtocolErrorCategory::ObjectNotFound,
);
let mut missing_object_validator = request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
vec![],
);
missing_object_validator.object_validator = None;
assert_failure(
fixture
.publication
.dispatch(missing_object_validator)
.unwrap_err(),
CallErrorKind::ValidatorRequired,
ProtocolErrorCategory::InvalidRequest,
);
let mut stale_object_validator = request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
vec![],
);
stale_object_validator.object_validator = Some(Validator::new(b"stale-object"));
assert_failure(
fixture
.publication
.dispatch(stale_object_validator)
.unwrap_err(),
CallErrorKind::ValidatorMismatch,
ProtocolErrorCategory::InvalidRequest,
);
assert_failure(
fixture
.publication
.dispatch(request(fixture.catalog.clone(), None, "status", vec![]))
.unwrap_err(),
CallErrorKind::InterfaceValidatorRequired,
ProtocolErrorCategory::InvalidRequest,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
Some(Validator::new(b"stale-interface")),
"status",
vec![],
))
.unwrap_err(),
CallErrorKind::InterfaceValidatorMismatch,
ProtocolErrorCategory::InvalidRequest,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"missing",
vec![],
))
.unwrap_err(),
CallErrorKind::OperationNotFound,
ProtocolErrorCategory::OperationNotFound,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"publish",
vec![NamedValue {
name: "request".into(),
value: Value::String("not a record".into()),
}],
))
.unwrap_err(),
CallErrorKind::InvalidArguments,
ProtocolErrorCategory::InvalidArguments,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog,
Some(Validator::new(CATALOG_VALIDATOR)),
"backend_status",
vec![],
))
.unwrap_err(),
CallErrorKind::Implementation,
ProtocolErrorCategory::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, None,
"broken_result", "diagnostics",
vec![], BTreeMap::new(),
)) ))
.unwrap_err(); .unwrap_err();
assert_failure( assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch);
error, assert_eq!(
CallErrorKind::InvalidResult, error.protocol_error().code,
ProtocolErrorCategory::Internal, ProtocolErrorCode::InterfaceMismatch
); );
} }
} }
+4 -7
View File
@@ -1,5 +1,5 @@
use wip_host::CallErrorKind; use wip_host::CallErrorKind;
use wip_protocol::{OperationOutcome, ProtocolErrorCategory}; use wip_protocol::{INTERFACE_FORMAT_V1, ProtocolErrorCode};
use wip_public_api_example::{PublishOutcome, run}; use wip_public_api_example::{PublishOutcome, run};
#[test] #[test]
@@ -7,8 +7,8 @@ fn public_crates_support_the_complete_end_to_end_scenario() {
let report = run().unwrap(); let report = run().unwrap();
assert_eq!(report.published_interfaces.len(), 2); assert_eq!(report.published_interfaces.len(), 2);
assert_eq!(report.catalog_descriptor, report.published_interfaces[0]); assert_eq!(report.catalog_descriptor_format, INTERFACE_FORMAT_V1);
assert_eq!(report.audit_descriptor, report.published_interfaces[1]); assert_eq!(report.audit_descriptor_format, INTERFACE_FORMAT_V1);
assert_eq!( assert_eq!(
report.published, report.published,
PublishOutcome::Published("article-1".into()) PublishOutcome::Published("article-1".into())
@@ -20,8 +20,5 @@ fn public_crates_support_the_complete_end_to_end_scenario() {
assert_eq!(report.catalog_status, "catalog-ready"); assert_eq!(report.catalog_status, "catalog-ready");
assert_eq!(report.audit_status, "audit-ready"); assert_eq!(report.audit_status, "audit-ready");
assert_eq!(report.host_failure, CallErrorKind::Implementation); assert_eq!(report.host_failure, CallErrorKind::Implementation);
assert!(matches!( assert_eq!(report.protocol_failure.code, ProtocolErrorCode::Internal);
report.protocol_failure.outcome,
OperationOutcome::Failed(error) if error.category == ProtocolErrorCategory::Internal
));
} }