Compare commits

...
12 changed files with 1536 additions and 1174 deletions
+1 -20
View File
@@ -1,22 +1,3 @@
[workspace]
members = [
".",
"crates/wip-host",
"crates/wip-interface",
"crates/wip-interface-macros",
"crates/wip-protocol",
]
members = ["crates/*"]
resolver = "3"
[package]
name = "wip-public-api-example"
version = "0.1.0"
edition = "2024"
description = "End-to-end example of the public WIP Rust APIs"
license = "MIT OR Apache-2.0"
publish = false
[dependencies]
wip-host = { path = "crates/wip-host" }
wip-interface = { path = "crates/wip-interface" }
wip-protocol = { path = "crates/wip-protocol" }
+56 -29
View File
@@ -2,9 +2,12 @@
Rust building blocks for the Web Interface Protocol (WIP).
The repository currently provides a transport-independent protocol model, a
Rust interface-authoring facade, and a request-scoped host runtime. HTTP and
other transport bindings are intentionally outside the supported slice.
The repository is a resolver-3 virtual Cargo workspace whose packages all live
under `crates/*`. It provides a transport-independent protocol model, a Rust
interface-authoring facade, a request-scoped host runtime, and a private
public-API conformance consumer. The repository root is not itself a package.
HTTP and other transport bindings are intentionally outside the supported
slice.
## Crate graph and responsibilities
@@ -36,19 +39,25 @@ or application traits.
## End-to-end example
[`src/lib.rs`](src/lib.rs) is the complete public-API example and
[`src/main.rs`](src/main.rs) runs it. It demonstrates all of the following:
[`crates/wip-public-api-example/src/lib.rs`](crates/wip-public-api-example/src/lib.rs)
is the complete public-API example and
[`crates/wip-public-api-example/src/main.rs`](crates/wip-public-api-example/src/main.rs)
runs it. It demonstrates all of the following:
1. public application DTOs derived as `WipType` records and unions;
1. public application DTOs derived as `WipType` records, enums, and unions,
including canonical entry paths and schema-opaque JSON;
2. two public traits declared with `#[wip::interface]`;
3. a private host struct implementing both traits;
4. explicit publication of both interfaces on one object;
5. registry observation and validation of generated descriptors;
6. calls made with interface references, operation names, and `Value` arguments;
4. explicit publication of both interfaces as an ordered set on one object;
5. separate observation of opaque interface references, descriptors, and
descriptor validators;
6. calls qualified by `target.path`, object validator, interface reference and
validator, operation, and schema-neutral `Value` arguments;
7. validated result values decoded back to the typed application result;
8. independent dispatch of the same `status` operation name on both interfaces;
9. a domain rejection returned as data and a backend failure returned as a host
error.
9. distinct domain rejection, host/backend failure, `PermissionDenied`, stale
validator recovery, interface membership, and lookup failure codes; and
10. canonical failure precedence when several request fields are invalid.
Run it inside the development shell:
@@ -57,10 +66,16 @@ nix develop
cargo run --package wip-public-api-example
```
The normal integration test is in [`tests/end_to_end.rs`](tests/end_to_end.rs).
Unit tests beside the example additionally cover every host dispatch failure,
invalid arguments and results, interface membership, validators, and
same-named operations.
The public-only conformance suite is in
[`crates/wip-public-api-example/tests/end_to_end.rs`](crates/wip-public-api-example/tests/end_to_end.rs).
It covers canonical object/descriptor shape, ordered interfaces,
schema-neutral record/enum/union/entry/JSON values, fully qualified calls, and
the distinct domain, host, authorization, validator-recovery, and interface
failure classes. Exhaustive public type construction and matching make legacy
ObjectId, descriptor `name`, and semantic `Value` variants compile-time
regressions. Unit tests in the public crates additionally cover every host
dispatch failure, invalid arguments and results, interface membership,
validators, and same-named operations.
## Interface authoring
@@ -174,22 +189,30 @@ references are rejected. Registry references are opaque and remain outside the
descriptor; generated descriptors contain neither those references nor Rust
trait identity.
Dispatch is qualified by interface reference and operation name. The host
checks current object identity and validator policy, current interface
membership, interface validator, descriptor operation, arguments, and result
while retaining the selected object and registered artifact. Host-private
fields and backend handles are never projected by `WipObject`.
Dispatch is qualified by interface reference and operation name and targets a
canonical Worldspace path. An `ObjectProvider` validates that path before
resolution, maps malformed paths to `InvalidRequest`, then resolves a valid path
exactly once inside `with_object`; the host checks the supplied object validator,
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
through `Ok`. `wip::HostError` is reserved for runtime/backend failures. Invalid
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,
stable-handle scope, or conditional-update primitive before constructing and
dispatching the request-scoped `PublishedObject`, and retain it until dispatch
returns. The runtime never validates one object and then resolves a second
snapshot, but it cannot make an arbitrary external backend atomic by itself.
stable-handle scope, or conditional-update primitive before invoking the
`with_object` callback, and retain it until the callback returns. Mutable
operations must make validator comparison and backend commit atomic and return a
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
@@ -200,10 +223,14 @@ nix develop
cargo fmt --all -- --check
cargo check --workspace --all-targets
cargo test --workspace --all-targets
cargo test --workspace --doc
cargo clippy --workspace --all-targets -- -D warnings
RUSTDOCFLAGS='-D warnings' cargo doc --workspace --no-deps
cargo run --package wip-public-api-example
git diff --check
```
`cargo test --workspace --all-targets` includes the public example integration
test, runtime failure tests, and `trybuild` compile-fail suites. Run
`cargo test --workspace --doc` when validating the checked rustdoc examples
separately.
`cargo test --workspace --all-targets` includes the public-only conformance
consumer, runtime failure tests, and `trybuild` compile-fail suites. The separate
doctest, rustdoc, executable, and whitespace checks are all required acceptance
steps.
+231 -160
View File
@@ -3,30 +3,26 @@
//! # Responsibility boundaries
//!
//! [`InterfaceRegistry::register`] validates and freezes a generated interface
//! descriptor, assigns an opaque [`InterfaceRef`], and makes the same descriptor
//! observation available to clients and dispatch. [`PublishedObject::expose`]
//! explicitly combines a request-scoped Rust object with a registered interface;
//! its [`InterfaceImplementation`] bound proves at compile time that the object
//! implements that interface. Implementing a Rust trait alone never publishes it,
//! descriptor, assigns an opaque [`InterfaceRef`], and leaves that reference
//! outside the descriptor. [`PublishedObject::expose`] explicitly combines a
//! request-scoped Rust object with a registered interface; its
//! [`InterfaceImplementation`] bound proves at compile time that the object
//! implements the interface. Implementing a Rust trait alone never publishes it,
//! and a publication with no interfaces is valid.
//!
//! [`PublishedObject::dispatch`] selects by `(InterfaceRef, operation name)`,
//! checks object and interface validators, current membership, the frozen
//! descriptor, and then invokes the matching generated typed dispatcher on the
//! same object instance. Arguments and results cross the protocol [`Value`]
//! boundary only through generated validation and codecs. Rust fields that are
//! not returned by [`WipObject`] are host-private and are never serialized by
//! this crate.
//! [`ObjectProvider::call_operation`] resolves `target.path` exactly once and
//! keeps the resulting object, interface membership, descriptor, implementation,
//! and backend target fixed within the provider's [`ObjectProvider::with_object`]
//! callback. Dispatch is qualified by `(interface reference, operation name)`.
//! The same frozen operation declaration validates both arguments and results.
//!
//! The call method is deliberately an execution boundary rather than a global
//! object registry: a provider may resolve and publish an object per request and
//! drop it afterward. The provider must enter any backend-specific transaction,
//! lock, stable-handle scope, or conditional-update primitive *before* creating
//! or dispatching the publication and retain it until dispatch returns. This
//! crate keeps validator checks, descriptor and implementation selection, and
//! invocation in one borrow of that publication; it does not claim that an
//! arbitrary backend stays stable, and it never reopens a path or resolves a
//! second implementation snapshot after validation.
//! A provider is responsible for entering a backend transaction, lock,
//! stable-handle scope, or conditional-update primitive before invoking the
//! callback and retaining it until the callback returns. The runtime does not
//! reopen the path or select another implementation after checking validators,
//! but cannot make an arbitrary external backend atomic on its own. Mutable
//! operations must make object-validator comparison and backend commit atomic;
//! the validator returned after dispatch must describe the committed state.
#![deny(missing_docs)]
@@ -41,8 +37,13 @@ use std::sync::atomic::{AtomicU64, Ordering};
use wip_interface::DispatchError;
pub use wip_interface::{InterfaceDefinition, InterfaceImplementation};
pub use wip_protocol;
pub use wip_protocol::{
CallOperationRequest as CallRequest, CallOperationResponse as CallResult, InterfaceTarget,
Target,
};
use wip_protocol::{
InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode, ValidationError, Value,
FetchInterfaceResponse, InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode,
ValidationError, Value,
};
/// Opaque bytes used as a conditional execution precondition.
@@ -63,14 +64,6 @@ impl Validator {
}
}
/// Host-local identity used by the legacy request-scoped publication API.
///
/// Canonical protocol calls target a Worldspace path. This wrapper remains
/// temporarily host-local so T-5 can adapt generated values without assigning
/// object identity semantics to the protocol descriptor model.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ObjectId(pub String);
/// Opaque host-assigned reference to one registered interface artifact.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct InterfaceRef(String);
@@ -83,22 +76,34 @@ impl InterfaceRef {
}
}
/// An immutable observation returned for an opaque interface reference.
/// One atomic observation of an interface reference, descriptor, and validator.
#[derive(Debug, Clone, PartialEq)]
pub struct InterfaceObservation {
/// Opaque reference selected by published objects and operation calls.
pub reference: InterfaceRef,
/// Canonical descriptor frozen when the interface was registered.
pub descriptor: InterfaceDescriptor,
/// Optional validator. Its presence makes it a required call precondition.
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)]
struct InterfaceArtifact {
reference: InterfaceRef,
validator: Option<Validator>,
descriptor: InterfaceDescriptor,
validator: Option<Validator>,
}
/// A typed registration token for a generated interface definition.
@@ -129,12 +134,11 @@ impl<D> RegisteredInterface<D> {
/// Registry of validated, immutable interface artifacts.
///
/// This registry stores interface definitions, not object instances. Generated
/// definitions are registered explicitly and receive process-local opaque
/// Generated descriptors are never rewritten to contain their assigned
/// references. A validator-free artifact is immutable for the lifetime of its
/// reference; a validator-bearing artifact requires that exact validator on a
/// call. Re-registering a definition is rejected instead of silently changing
/// the descriptor associated with an existing reference.
/// reference. A validator-bearing artifact requires that exact validator on a
/// call. Re-registering a definition is rejected rather than silently changing
/// the observation associated with an existing reference.
#[derive(Debug)]
pub struct InterfaceRegistry {
namespace: u64,
@@ -164,12 +168,6 @@ impl InterfaceRegistry {
}
/// Registers and freezes one generated interface definition.
///
/// `validator` should be present when the host intends the reference to
/// represent a versioned descriptor. Its presence makes the same value a
/// mandatory precondition for dispatch. Registration finalizes the
/// descriptor remains independent of both registration and its assigned
/// opaque reference.
pub fn register<D>(
&mut self,
_definition: D,
@@ -198,8 +196,8 @@ impl InterfaceRegistry {
.ok_or(RegistrationError::ReferenceSpaceExhausted)?;
let artifact = Arc::new(InterfaceArtifact {
reference: reference.clone(),
validator,
descriptor,
validator,
});
self.definitions.insert(definition);
self.artifacts.insert(reference, Arc::clone(&artifact));
@@ -211,28 +209,40 @@ impl InterfaceRegistry {
}
/// 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]
pub fn resolve(&self, reference: &str) -> Option<InterfaceRef> {
let reference = InterfaceRef(reference.to_owned());
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]
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
.get(reference)
.map(|artifact| InterfaceObservation {
reference: artifact.reference.clone(),
validator: artifact.validator.clone(),
descriptor: artifact.descriptor.clone(),
.get(&reference)
.map(Self::observation)
.ok_or_else(|| ProtocolError {
code: ProtocolErrorCode::InterfaceNotFound,
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.
@@ -274,19 +284,32 @@ impl Error for RegistrationError {
/// Public metadata and conditional-execution policy for a host-private object.
///
/// Only values returned by this trait are projected into protocol metadata.
/// Backend handles, filesystem paths, credentials, and other private fields on
/// the implementing type are neither inspected nor serialized.
/// Paths, children, backend handles, credentials, and resolution state are not
/// part of this API and therefore cannot be serialized into [`Object`].
pub trait WipObject {
/// Returns the current logical identity of this object.
fn id(&self) -> ObjectId;
/// Returns the public path-segment name of this object.
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.
fn validator(&self) -> Option<&Validator> {
None
}
/// Whether a call must include the current object validator.
fn requires_validator(&self) -> bool {
/// Whether this specific qualified operation requires an object validator.
///
/// 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
}
}
@@ -301,7 +324,8 @@ struct BoundInterface<T> {
/// 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
/// 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> {
implementation: T,
interfaces: Vec<BoundInterface<T>>,
@@ -349,63 +373,46 @@ impl<T: WipObject> PublishedObject<T> {
&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]
pub fn protocol_object(&self) -> Object {
let id = self.implementation.id();
let object = Object {
name: id.0.clone(),
description: None,
Object {
name: self.implementation.name().to_owned(),
description: self.implementation.description().map(str::to_owned),
interfaces: self
.interfaces
.iter()
.map(|bound| bound.artifact.reference.0.clone())
.collect(),
r#ref: Some(id.0),
r#ref: self.implementation.reference().map(str::to_owned),
validator: self
.implementation
.validator()
.map(|validator| validator.0.clone()),
};
debug_assert!(object.validate().is_ok());
object
}
}
/// Dispatches one qualified operation against this current publication.
///
/// Preconditions are checked before generated operation code runs. The
/// selected artifact and function pointer are borrowed once and are not
/// resolved again after validator checks.
pub fn dispatch(&self, request: CallRequest) -> Result<CallResult, CallError> {
if request.object != self.implementation.id() {
fn dispatch_resolved(
&self,
request: CallRequest,
authorize: impl FnOnce(&T, &InterfaceRef, &str) -> bool,
) -> Result<CallResult, CallError> {
let current_object_validator = self.implementation.validator();
if let Some(supplied) = request.target.validator.as_deref()
&& current_object_validator.map(Validator::as_bytes) != Some(supplied)
{
return Err(CallError::new(
CallErrorKind::ObjectNotFound,
"call target does not identify this published object",
CallErrorKind::ValidatorMismatch,
"object validator does not match current object state",
));
}
let current_object_validator = self.implementation.validator();
match request.object_validator.as_ref() {
Some(supplied) if Some(supplied) != current_object_validator => {
return Err(CallError::new(
CallErrorKind::ValidatorMismatch,
"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
.interfaces
.iter()
.find(|bound| bound.artifact.reference == request.interface.reference)
else {
let Some(bound) = self.interfaces.iter().find(|bound| {
bound.artifact.reference.as_str() == request.interface.reference.as_str()
}) else {
return Err(CallError::new(
CallErrorKind::InterfaceMismatch,
"interface is not a member of the current object publication",
@@ -414,7 +421,7 @@ impl<T: WipObject> PublishedObject<T> {
match (
bound.artifact.validator.as_ref(),
request.interface.validator.as_ref(),
request.interface.validator.as_deref(),
) {
(Some(_), None) => {
return Err(CallError::new(
@@ -422,7 +429,7 @@ impl<T: WipObject> PublishedObject<T> {
"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(
CallErrorKind::InterfaceValidatorMismatch,
"interface validator does not match the registered descriptor",
@@ -444,12 +451,34 @@ impl<T: WipObject> PublishedObject<T> {
));
}
if request.target.validator.is_none()
&& self
.implementation
.requires_validator(&bound.artifact.reference, &request.operation)
{
return Err(CallError::new(
CallErrorKind::ValidatorRequired,
"this operation requires an object validator",
));
}
bound
.artifact
.descriptor
.validate_arguments(&request.operation, &request.arguments)
.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)
.map_err(CallError::from_dispatch)?;
@@ -460,8 +489,11 @@ impl<T: WipObject> PublishedObject<T> {
.map_err(|error| CallError::new(CallErrorKind::InvalidResult, error.to_string()))?;
Ok(CallResult {
value,
validator: self.implementation.validator().cloned(),
result: value,
validator: self
.implementation
.validator()
.map(|validator| validator.0.clone()),
})
}
@@ -472,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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PublicationError {
@@ -493,44 +594,13 @@ impl Display for PublicationError {
impl Error for PublicationError {}
/// Interface portion of a qualified operation target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterfaceTarget {
/// Opaque reference selected from the current object's interface set.
pub reference: InterfaceRef,
/// Validator copied from the descriptor observation, when one was present.
pub validator: Option<Validator>,
}
/// One transport-independent request to a published object operation.
#[derive(Debug, Clone, PartialEq)]
pub struct CallRequest {
/// Current logical object identity.
pub object: ObjectId,
/// Optional current object-state precondition.
pub object_validator: Option<Validator>,
/// Qualified interface reference and descriptor precondition.
pub interface: InterfaceTarget,
/// Operation name within the selected interface descriptor.
pub operation: String,
/// Named protocol arguments.
pub arguments: BTreeMap<String, Value>,
}
/// Successful host dispatch result.
#[derive(Debug, Clone, PartialEq)]
pub struct CallResult {
/// Encoded application result. No-data operations return [`Value::Unit`].
pub value: Value,
/// Object validator observed after the implementation returned.
pub validator: Option<Validator>,
}
/// Stable host dispatch failure kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CallErrorKind {
/// The call names a different or unavailable object.
ObjectNotFound,
/// The request is structurally invalid.
InvalidRequest,
/// The target path is not currently published.
NotFound,
/// The selected interface is not exposed by the current object.
InterfaceMismatch,
/// The operation is absent from the selected descriptor.
@@ -539,7 +609,7 @@ pub enum CallErrorKind {
InvalidArguments,
/// The encoded implementation result violates its declaration.
InvalidResult,
/// This object requires a validator and the call omitted it.
/// This operation requires a validator and the call omitted it.
ValidatorRequired,
/// The supplied object validator is stale or belongs to another object.
ValidatorMismatch,
@@ -547,6 +617,8 @@ pub enum CallErrorKind {
InterfaceValidatorRequired,
/// The supplied interface validator does not identify the current descriptor.
InterfaceValidatorMismatch,
/// The operation is declared but not authorized for the caller.
PermissionDenied,
/// The typed implementation reported a host/backend failure.
Implementation,
}
@@ -602,30 +674,29 @@ impl CallError {
&self.message
}
/// Maps this host failure to the existing 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.
/// Maps this host failure to the canonical protocol error taxonomy.
#[must_use]
pub fn protocol_error(&self) -> ProtocolError {
let category = match self.kind {
CallErrorKind::ObjectNotFound => ProtocolErrorCode::NotFound,
let code = match self.kind {
CallErrorKind::InvalidRequest => ProtocolErrorCode::InvalidRequest,
CallErrorKind::NotFound => ProtocolErrorCode::NotFound,
CallErrorKind::InterfaceMismatch => ProtocolErrorCode::InterfaceMismatch,
CallErrorKind::OperationNotFound => ProtocolErrorCode::OperationNotFound,
CallErrorKind::InvalidArguments => ProtocolErrorCode::InvalidArguments,
CallErrorKind::ValidatorRequired
| CallErrorKind::ValidatorMismatch
| CallErrorKind::InterfaceValidatorRequired
| CallErrorKind::InterfaceValidatorMismatch => ProtocolErrorCode::InvalidRequest,
CallErrorKind::InvalidResult | CallErrorKind::Implementation => {
ProtocolErrorCode::Internal
CallErrorKind::InvalidResult => ProtocolErrorCode::InvalidResult,
CallErrorKind::ValidatorRequired => ProtocolErrorCode::ValidatorRequired,
CallErrorKind::ValidatorMismatch => ProtocolErrorCode::ValidatorMismatch,
CallErrorKind::InterfaceValidatorRequired => {
ProtocolErrorCode::InterfaceValidatorRequired
}
CallErrorKind::InterfaceValidatorMismatch => {
ProtocolErrorCode::InterfaceValidatorMismatch
}
CallErrorKind::PermissionDenied => ProtocolErrorCode::PermissionDenied,
CallErrorKind::Implementation => ProtocolErrorCode::Internal,
};
ProtocolError {
code: category,
code,
message: self.message.clone(),
}
}
+477 -321
View File
@@ -1,9 +1,10 @@
use std::cell::{Cell, RefCell};
use std::cell::Cell;
use std::collections::BTreeMap;
use std::rc::Rc;
use wip_host::{
CallErrorKind, CallRequest, InterfaceRegistry, InterfaceTarget, ObjectId, PublishedObject,
Validator, WipObject,
CallError, CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget,
ObjectProvider, PublishedObject, RegisteredInterface, Target, Validator, WipObject,
};
use wip_interface::{
CodecError, HostError, OperationResult, TypeDeclaration, TypeExpr, Value, WipType, interface,
@@ -17,6 +18,9 @@ pub trait Reader {
/// Adds two integers.
fn add(&self, left: i64, right: i64) -> OperationResult<i64>;
/// Reports a backend failure.
fn fail(&self) -> OperationResult<String>;
}
#[interface]
@@ -57,31 +61,37 @@ impl WipType for BadValue {
}
struct Document {
id: String,
name: String,
description: Option<String>,
reference: Option<String>,
validator: Option<Validator>,
require_validator: bool,
calls: Cell<usize>,
private_backend_handle: RefCell<String>,
calls: Rc<Cell<usize>>,
private_backend_handle: String,
}
impl Document {
fn new(id: &str) -> Self {
fn open(name: &str, reference: Option<&str>, calls: Rc<Cell<usize>>) -> Self {
Self {
id: id.into(),
name: name.into(),
description: Some("public document".into()),
reference: reference.map(str::to_owned),
validator: None,
require_validator: false,
calls: Cell::new(0),
private_backend_handle: RefCell::new("/private/backend/path".into()),
calls,
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 {
id: id.into(),
name: name.into(),
description: Some("public document".into()),
reference: Some(reference.into()),
validator: Some(Validator::new(validator)),
require_validator: true,
calls: Cell::new(0),
private_backend_handle: RefCell::new("secret-handle".into()),
calls,
private_backend_handle: "secret-handle".into(),
}
}
@@ -91,15 +101,23 @@ impl Document {
}
impl WipObject for Document {
fn id(&self) -> ObjectId {
ObjectId(self.id.clone())
fn name(&self) -> &str {
&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> {
self.validator.as_ref()
}
fn requires_validator(&self) -> bool {
fn requires_validator(&self, _: &InterfaceRef, _: &str) -> bool {
self.require_validator
}
}
@@ -114,6 +132,11 @@ impl Reader for Document {
self.record_call();
Ok(left + right)
}
fn fail(&self) -> OperationResult<String> {
self.record_call();
Err(HostError::new("backend unavailable"))
}
}
impl Writer for Document {
@@ -137,366 +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(
object: &str,
path: &str,
object_validator: Option<&[u8]>,
reference: &wip_host::InterfaceRef,
reference: &InterfaceRef,
interface_validator: Option<&[u8]>,
operation: &str,
arguments: BTreeMap<String, Value>,
) -> CallRequest {
CallRequest {
object: ObjectId(object.into()),
object_validator: object_validator.map(Validator::new),
target: Target {
path: path.into(),
validator: object_validator.map(<[u8]>::to_vec),
},
interface: InterfaceTarget {
reference: reference.clone(),
validator: interface_validator.map(Validator::new),
reference: reference.as_str().to_owned(),
validator: interface_validator.map(<[u8]>::to_vec),
},
operation: operation.into(),
arguments,
}
}
#[test]
fn registry_exposes_opaque_validated_descriptor_observations() {
let mut registry = InterfaceRegistry::new();
let reader = registry.register(ReaderDefinition, None).unwrap();
assert_ne!(reader.reference().as_str(), "Reader");
assert_eq!(
registry.resolve(reader.reference().as_str()).as_ref(),
Some(reader.reference())
);
assert!(registry.resolve("wip-interface:unknown:0").is_none());
let observation = registry.observe(reader.reference()).unwrap();
assert_eq!(observation.reference, *reader.reference());
assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
assert_eq!(observation.validator, None);
observation.descriptor.validate().unwrap();
assert!(registry.register(ReaderDefinition, None).is_err());
let mut other_registry = InterfaceRegistry::new();
let other_reader = other_registry.register(ReaderDefinition, None).unwrap();
assert_ne!(reader.reference(), other_reader.reference());
assert!(
registry
.resolve(other_reader.reference().as_str())
.is_none()
);
fn assert_failure(error: CallError, kind: CallErrorKind, code: ProtocolErrorCode) {
assert_eq!(error.kind(), kind);
assert_eq!(error.protocol_error().code, code);
}
#[test]
fn publication_is_explicit_ordered_qualified_and_request_scoped() {
let mut registry = InterfaceRegistry::new();
let reader = registry.register(ReaderDefinition, None).unwrap();
let writer = registry.register(WriterDefinition, None).unwrap();
let _hidden = registry.register(HiddenDefinition, None).unwrap();
fn registry_returns_atomic_canonical_observation_without_rewriting_descriptor() {
let (registry, provider, _) = registry_and_provider();
let reference = provider.reader.reference();
let publication = PublishedObject::new(Document::new("document-1"))
.expose(&writer)
.unwrap()
.expose(&reader)
.unwrap();
assert_ne!(reference.as_str(), "Reader");
let observation = registry.fetch_interface(reference.as_str()).unwrap();
assert_eq!(observation.reference, *reference);
assert_eq!(observation.descriptor, ReaderDefinition::descriptor());
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!(
object.interfaces,
vec![
writer.reference().as_str().to_owned(),
reader.reference().as_str().to_owned()
provider.writer.reference().as_str().to_owned(),
provider.reader.reference().as_str().to_owned(),
]
);
registry
.observe(writer.reference())
.unwrap()
.descriptor
.validate()
.unwrap();
registry
.observe(reader.reference())
.unwrap()
.descriptor
.validate()
.unwrap();
assert!(!format!("{object:?}").contains("private/backend/path"));
assert!(!format!("{object:?}").contains("secret-handle"));
assert!(!format!("{object:?}").contains("/document"));
let writer_result = publication
.dispatch(request(
"document-1",
None,
writer.reference(),
None,
"label",
BTreeMap::new(),
))
.unwrap();
let reader_result = publication
.dispatch(request(
"document-1",
None,
reader.reference(),
None,
"label",
BTreeMap::new(),
))
.unwrap();
assert_eq!(writer_result.value, Value::String("writer".into()));
assert_eq!(reader_result.value, Value::String("reader".into()));
assert_eq!(publication.implementation().calls.get(), 2);
let empty = provider.fetch("/empty").unwrap();
assert!(empty.interfaces.is_empty());
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,
Rc::clone(&provider.calls),
))
.expose(&provider.reader)
.unwrap();
assert_eq!(
publication
.implementation()
.private_backend_handle
.borrow()
.as_str(),
publication.implementation().private_backend_handle,
"/private/backend/path"
);
}
#[test]
fn implemented_but_unexposed_interface_is_not_a_member() {
let mut registry = InterfaceRegistry::new();
let reader = registry.register(ReaderDefinition, None).unwrap();
let hidden = registry.register(HiddenDefinition, None).unwrap();
let publication = PublishedObject::new(Document::new("document-2"))
.expose(&reader)
.unwrap();
let error = publication
.dispatch(request(
"document-2",
None,
hidden.reference(),
None,
"hidden",
BTreeMap::new(),
))
.unwrap_err();
assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch);
assert_eq!(
error.protocol_error().code,
ProtocolErrorCode::InterfaceMismatch
);
assert_eq!(publication.implementation().calls.get(), 0);
}
#[test]
fn zero_interface_objects_are_valid() {
let publication = PublishedObject::new(Document::new("empty"));
let object = publication.protocol_object();
assert_eq!(object.name, "empty");
assert_eq!(object.r#ref.as_deref(), Some("empty"));
assert!(object.interfaces.is_empty());
object.validate().unwrap();
}
#[test]
fn duplicate_exposure_is_rejected_without_reordering() {
let mut registry = InterfaceRegistry::new();
let reader = registry.register(ReaderDefinition, None).unwrap();
let publication = PublishedObject::new(Document::new("duplicate"))
.expose(&reader)
.unwrap();
let error = match publication.expose(&reader) {
let error = match publication.expose(&provider.reader) {
Ok(_) => panic!("duplicate exposure unexpectedly succeeded"),
Err(error) => error,
};
assert!(error.to_string().contains(reader.reference().as_str()));
}
#[test]
fn operation_and_argument_failures_are_mapped_without_execution() {
let mut registry = InterfaceRegistry::new();
let reader = registry.register(ReaderDefinition, None).unwrap();
let publication = PublishedObject::new(Document::new("validation"))
.expose(&reader)
.unwrap();
let missing = publication
.dispatch(request(
"validation",
None,
reader.reference(),
None,
"missing",
BTreeMap::new(),
))
.unwrap_err();
assert_eq!(missing.kind(), CallErrorKind::OperationNotFound);
assert_eq!(
missing.protocol_error().code,
ProtocolErrorCode::OperationNotFound
assert!(
error
.to_string()
.contains(provider.reader.reference().as_str())
);
let invalid = publication
.dispatch(request(
"validation",
None,
reader.reference(),
None,
"add",
BTreeMap::from([("left".into(), Value::String("wrong".into()))]),
))
.unwrap_err();
assert_eq!(invalid.kind(), CallErrorKind::InvalidArguments);
assert_eq!(
invalid.protocol_error().code,
ProtocolErrorCode::InvalidArguments
);
assert_eq!(publication.implementation().calls.get(), 0);
}
#[test]
fn invalid_implementation_result_is_internal_and_not_returned() {
let mut registry = InterfaceRegistry::new();
let broken = registry.register(BrokenDefinition, None).unwrap();
let publication = PublishedObject::new(Document::new("broken"))
.expose(&broken)
.unwrap();
fn paths_are_resolution_context_not_object_identity() {
let (_, provider, _) = registry_and_provider();
let error = publication
.dispatch(request(
"broken",
None,
broken.reference(),
None,
"broken",
BTreeMap::new(),
))
.unwrap_err();
assert_eq!(error.kind(), CallErrorKind::InvalidResult);
assert_eq!(error.protocol_error().code, ProtocolErrorCode::Internal);
assert_eq!(publication.implementation().calls.get(), 1);
let left = provider.fetch("/left/document").unwrap();
let right = provider.fetch("/right/document").unwrap();
assert_eq!(left, right);
assert_eq!(left.r#ref.as_deref(), Some("shared-ref"));
let first = provider.fetch("/switch").unwrap();
let second = provider.fetch("/switch").unwrap();
assert_ne!(first.r#ref, second.r#ref);
assert_ne!(first.validator, second.validator);
}
#[test]
fn object_validator_missing_and_mismatch_are_distinct_and_prevent_execution() {
let mut registry = InterfaceRegistry::new();
let reader = registry.register(ReaderDefinition, None).unwrap();
let publication = PublishedObject::new(Document::strict("strict", b"object-v1"))
.expose(&reader)
.unwrap();
fn path_dispatch_is_interface_qualified_and_resolves_once_per_call() {
let (_, provider, _) = registry_and_provider();
let before = provider.resolutions.get();
let missing = publication
.dispatch(request(
"strict",
let writer = provider
.call_operation(request(
"/open",
None,
reader.reference(),
None,
"label",
BTreeMap::new(),
))
.unwrap_err();
assert_eq!(missing.kind(), CallErrorKind::ValidatorRequired);
let mismatch = publication
.dispatch(request(
"strict",
Some(b"stale"),
reader.reference(),
None,
"label",
BTreeMap::new(),
))
.unwrap_err();
assert_eq!(mismatch.kind(), CallErrorKind::ValidatorMismatch);
assert_eq!(publication.implementation().calls.get(), 0);
let result = publication
.dispatch(request(
"strict",
Some(b"object-v1"),
reader.reference(),
provider.writer.reference(),
None,
"label",
BTreeMap::new(),
))
.unwrap();
assert_eq!(result.validator, Some(Validator::new(b"object-v1")));
assert_eq!(publication.implementation().calls.get(), 1);
}
#[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",
let reader = provider
.call_operation(request(
"/open",
None,
writer.reference(),
None,
"label",
BTreeMap::new(),
))
.unwrap_err();
assert_eq!(missing.kind(), CallErrorKind::InterfaceValidatorRequired);
let mismatch = publication
.dispatch(request(
"mutable-interface",
None,
writer.reference(),
Some(b"schema-v0"),
"label",
BTreeMap::new(),
))
.unwrap_err();
assert_eq!(mismatch.kind(), CallErrorKind::InterfaceValidatorMismatch);
assert_eq!(publication.implementation().calls.get(), 0);
let result = publication
.dispatch(request(
"mutable-interface",
None,
writer.reference(),
provider.reader.reference(),
Some(b"schema-v1"),
"label",
BTreeMap::new(),
))
.unwrap();
assert_eq!(result.value, Value::String("writer".into()));
let strict = provider
.call_operation(request(
"/document",
Some(b"object-v1"),
provider.reader.reference(),
Some(b"schema-v1"),
"label",
BTreeMap::new(),
))
.unwrap();
assert_eq!(writer.result, Value::String("writer".into()));
assert_eq!(reader.result, Value::String("reader".into()));
assert_eq!(strict.result, Value::String("reader".into()));
assert_eq!(strict.validator.as_deref(), Some(b"object-v1".as_slice()));
assert_eq!(provider.resolutions.get() - before, 3);
assert_eq!(provider.calls.get(), 3);
}
#[test]
fn host_failures_map_to_internal_without_becoming_domain_values() {
#[interface]
pub trait Failing {
/// Always fails in the host implementation.
fn fail(&self) -> OperationResult<String>;
}
fn call_failures_follow_canonical_precedence_without_execution() {
let (_, provider, hidden) = registry_and_provider();
impl Failing for Document {
fn fail(&self) -> OperationResult<String> {
Err(HostError::new("backend unavailable"))
}
let resolutions = provider.resolutions.get();
for invalid_path in [
"relative",
"/trailing/",
"/double//segment",
"/dot/./segment",
"/parent/../segment",
] {
assert_failure(
provider
.call_operation(request(
invalid_path,
Some(b"stale"),
hidden.reference(),
Some(b"stale"),
"missing",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::InvalidRequest,
ProtocolErrorCode::InvalidRequest,
);
}
assert_eq!(provider.resolutions.get(), resolutions);
assert_failure(
provider.fetch("/invalid/").unwrap_err(),
CallErrorKind::InvalidRequest,
ProtocolErrorCode::InvalidRequest,
);
assert_eq!(provider.resolutions.get(), resolutions);
let mut registry = InterfaceRegistry::new();
let failing = registry.register(FailingDefinition, None).unwrap();
let publication = PublishedObject::new(Document::new("failure"))
.expose(&failing)
.unwrap();
let error = publication
.dispatch(request(
"failure",
None,
failing.reference(),
None,
"fail",
BTreeMap::new(),
))
.unwrap_err();
assert_eq!(error.kind(), CallErrorKind::Implementation);
assert_eq!(error.protocol_error().code, ProtocolErrorCode::Internal);
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,4 +1,4 @@
use wip_host::{InterfaceRegistry, ObjectId, PublishedObject, WipObject};
use wip_host::{InterfaceRegistry, PublishedObject, WipObject};
use wip_interface::{OperationResult, interface};
#[interface]
@@ -9,8 +9,8 @@ pub trait Search {
struct Object;
impl WipObject for Object {
fn id(&self) -> ObjectId {
ObjectId("object".into())
fn name(&self) -> &str {
"object"
}
}
+12
View File
@@ -264,3 +264,15 @@ pub struct ProtocolError {
/// Bounded human-readable diagnostic. Consumers must not branch on it.
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 {}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "wip-public-api-example"
version = "0.1.0"
edition = "2024"
description = "Public-only end-to-end conformance consumer for the WIP Rust APIs"
license = "MIT OR Apache-2.0"
publish = false
[dependencies]
wip-host = { path = "../wip-host" }
wip-interface = { path = "../wip-interface" }
wip-protocol = { path = "../wip-protocol" }
+508
View File
@@ -0,0 +1,508 @@
//! Public-only conformance consumer for the WIP Rust crates.
//!
//! # Architectural boundary
//!
//! This package is an ordinary application consumer. Its direct dependencies are
//! the public `wip-protocol`, `wip-interface`, and `wip-host` crates; it neither
//! imports workspace-private modules nor depends on `wip-interface-macros`.
//! `wip-protocol` owns transport-independent DTOs and schema-neutral values,
//! while this package owns its application DTOs and interface traits. The
//! request-scoped implementation, backend handle, authorization policy, and
//! publication wiring remain host-private. A transport binding would translate
//! its own wire routes and envelopes to the protocol call DTOs; no HTTP type is
//! part of an application trait or interface descriptor.
//!
//! # Breaking migration exercised here
//!
//! The canonical API targets operations by `target.path` rather than a mandatory
//! object identifier. Object `ref` and object/interface validators are separate,
//! optional observation fields. Interface references are opaque values observed
//! separately from descriptors, and descriptors contain neither a Rust trait
//! name nor an opaque-reference `name` field. Application records, enums,
//! unions, entry paths, and JSON all encode through the schema-neutral [`Value`]
//! variants: entries and enums are strings, unions are discriminator records,
//! and JSON recursively uses ordinary records/lists/primitives. The former
//! `Entry<K, V>` and semantic `Value::Entry`, `Value::Enum`, `Value::Union`, and
//! `Value::Json` forms are intentionally unsupported.
//!
//! # Supported authoring subset
//!
//! Public, non-generic traits use immutable `&self` receivers, owned named
//! parameters, and explicit `wip::OperationResult<T>` returns. Derived DTOs are
//! named-field records, all-unit enums, or unions with unit and single-payload
//! cases. A direct `Option<T>` means field/argument omission, never protocol
//! null. See `wip-interface` for the complete rejected-syntax list.
#![deny(missing_docs)]
use std::collections::BTreeMap;
use std::error::Error;
use std::io;
use wip_host::{
CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget, ObjectProvider,
PublishedObject, RegisteredInterface, Target, Validator, WipObject,
};
use wip_interface as wip;
use wip_interface::WipType;
use wip_protocol::{ProtocolError, ProtocolErrorCode, Value};
const OBJECT_PATH: &str = "/article-service";
const OBJECT_VALIDATOR: &[u8] = b"object-v1";
const CATALOG_VALIDATOR: &[u8] = b"catalog-v1";
/// Publication visibility represented as a protocol enum string.
#[derive(Debug, Clone, PartialEq, Eq, wip::WipType)]
pub enum Visibility {
/// Visible to every authorized caller.
Public,
/// Visible only inside the application domain.
Private,
}
/// An application-owned request DTO mapped to a protocol record.
#[derive(Debug, Clone, PartialEq, wip::WipType)]
pub struct PublishRequest {
/// Title of the article to publish.
pub title: String,
/// Optional article body; omission is distinct from JSON null.
pub body: Option<String>,
/// Canonical Worldspace path to the source entry.
pub source: wip::Entry,
/// Application enum represented by a schema-neutral string.
pub visibility: Visibility,
/// Schema-opaque JSON represented by ordinary protocol values.
pub metadata: wip::JsonValue,
}
/// The application domain result of a publication attempt.
#[derive(Debug, Clone, PartialEq, Eq, wip::WipType)]
pub enum PublishOutcome {
/// Publication succeeded and returned its application identifier.
Published(String),
/// An application policy rejected the publication.
Rejected(String),
}
/// Article publication operations.
#[wip::interface]
pub trait Catalog {
/// Publishes an article or reports an application-domain rejection.
///
/// # Returns
/// The typed application outcome.
fn publish(
&self,
/// Application publication request.
request: PublishRequest,
) -> wip::OperationResult<PublishOutcome>;
/// Returns the catalog-qualified status.
fn status(&self) -> wip::OperationResult<String>;
/// Demonstrates a host/backend failure rather than a domain result.
fn backend_status(&self) -> wip::OperationResult<String>;
/// Demonstrates denial after every structural precondition succeeds.
fn restricted_status(&self) -> wip::OperationResult<String>;
}
/// Audit operations exposed by the same object.
#[wip::interface]
pub trait Audit {
/// Returns the audit-qualified status.
fn status(&self) -> wip::OperationResult<String>;
}
/// A registered but deliberately unexposed interface.
#[wip::interface]
pub trait Diagnostics {
/// Returns host-private diagnostics when explicitly exposed.
fn diagnostics(&self) -> wip::OperationResult<String>;
}
/// Observable outcomes from the complete public-API scenario.
#[derive(Debug, Clone, PartialEq)]
pub struct DemoReport {
/// Path-segment name from the canonical object projection.
pub object_name: String,
/// Optional object identity, distinct from the resolution path.
pub object_reference: Option<String>,
/// Optional object-state validator.
pub object_validator: Option<Vec<u8>>,
/// Opaque interface references published by the object, in exposure order.
pub published_interfaces: Vec<String>,
/// Opaque reference assigned to the catalog descriptor.
pub catalog_interface_reference: String,
/// Opaque reference assigned to the audit descriptor.
pub audit_interface_reference: String,
/// Descriptor format observed separately for the catalog reference.
pub catalog_descriptor_format: String,
/// Descriptor format observed separately for the audit reference.
pub audit_descriptor_format: String,
/// Typed successful application result decoded from a validated [`Value`].
pub published: PublishOutcome,
/// Typed application-domain rejection decoded from a validated [`Value`].
pub rejected: PublishOutcome,
/// Result of the catalog-qualified `status` operation.
pub catalog_status: String,
/// Result of the audit-qualified `status` operation.
pub audit_status: String,
/// Stable failure kind produced by the deliberate host/backend failure.
pub host_failure: CallErrorKind,
/// A protocol-level failure built from the host failure.
pub protocol_failure: ProtocolError,
/// Authorization failure after a fully qualified valid request.
pub permission_failure: ProtocolErrorCode,
/// Recovery signal for a stale object validator.
pub validator_failure: ProtocolErrorCode,
/// Failure for a known but unexposed interface reference.
pub interface_mismatch: ProtocolErrorCode,
/// Failure for an unknown interface observation reference.
pub interface_not_found: ProtocolErrorCode,
/// First failure for a request with an invalid path and later invalid fields.
pub precedence_failure: ProtocolErrorCode,
}
/// Runs the complete end-to-end conformance example.
///
/// The provider resolves the current object from a Worldspace path for each
/// call. Interface observations remain separate from object projection, and all
/// successful calls carry independent object and interface validators.
///
/// # Errors
///
/// Returns an error if registration, observation, path resolution, dispatch, or
/// application-value decoding fails unexpectedly.
pub fn run() -> Result<DemoReport, Box<dyn Error>> {
let mut registry = InterfaceRegistry::new();
let catalog = registry.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))?;
let audit = registry.register(AuditDefinition, None)?;
let diagnostics = registry.register(DiagnosticsDefinition, None)?;
let provider = ApplicationProvider {
catalog: catalog.clone(),
audit: audit.clone(),
};
let object = provider.fetch(OBJECT_PATH)?;
let catalog_observation = registry.fetch_interface(
object
.interfaces
.first()
.ok_or_else(|| io::Error::other("catalog interface was not published"))?,
)?;
let audit_observation = registry.fetch_interface(
object
.interfaces
.get(1)
.ok_or_else(|| io::Error::other("audit interface was not published"))?,
)?;
let published = dispatch_publish(
&provider,
catalog.reference(),
PublishRequest {
title: "Public APIs".into(),
body: Some("protocol values in, validated values out".into()),
source: wip::Entry::new("/drafts/public-apis")?,
visibility: Visibility::Public,
metadata: wip::JsonValue::Object(vec![wip::JsonMember {
name: "reviewed".into(),
value: wip::JsonValue::Boolean(true),
}]),
},
)?;
let rejected = dispatch_publish(
&provider,
catalog.reference(),
PublishRequest {
title: String::new(),
body: None,
source: wip::Entry::new("/drafts/untitled")?,
visibility: Visibility::Private,
metadata: wip::JsonValue::Null,
},
)?;
let catalog_status = dispatch_string(
&provider,
request(
OBJECT_PATH,
catalog.reference(),
Some(CATALOG_VALIDATOR),
"status",
BTreeMap::new(),
),
)?;
let audit_status = dispatch_string(
&provider,
request(
OBJECT_PATH,
audit.reference(),
None,
"status",
BTreeMap::new(),
),
)?;
let host_error = provider
.call_operation(request(
OBJECT_PATH,
catalog.reference(),
Some(CATALOG_VALIDATOR),
"backend_status",
BTreeMap::new(),
))
.expect_err("backend_status must model a host failure");
ensure_kind(&host_error, CallErrorKind::Implementation)?;
let protocol_failure = host_error.protocol_error();
let permission_failure = provider
.call_operation(request(
OBJECT_PATH,
catalog.reference(),
Some(CATALOG_VALIDATOR),
"restricted_status",
BTreeMap::new(),
))
.expect_err("restricted_status must be denied")
.protocol_error()
.code;
let validator_failure = provider
.call_operation(CallRequest {
target: Target {
path: OBJECT_PATH.into(),
validator: Some(b"stale-object".to_vec()),
},
interface: InterfaceTarget {
reference: diagnostics.reference().as_str().to_owned(),
validator: Some(b"stale-interface".to_vec()),
},
operation: "missing".into(),
arguments: BTreeMap::from([("unknown".into(), Value::Unit)]),
})
.expect_err("the stale object validator must fail first")
.protocol_error()
.code;
let interface_mismatch = provider
.call_operation(request(
OBJECT_PATH,
diagnostics.reference(),
None,
"diagnostics",
BTreeMap::new(),
))
.expect_err("registered but unexposed interface must not dispatch")
.protocol_error()
.code;
let interface_not_found = registry
.fetch_interface("missing-interface")
.expect_err("unknown interface references must not resolve")
.code;
let precedence_failure = provider
.call_operation(CallRequest {
target: Target {
path: "relative/path".into(),
validator: Some(b"stale-object".to_vec()),
},
interface: InterfaceTarget {
reference: diagnostics.reference().as_str().to_owned(),
validator: Some(b"stale-interface".to_vec()),
},
operation: "missing".into(),
arguments: BTreeMap::from([("unknown".into(), Value::Unit)]),
})
.expect_err("invalid path must have canonical precedence")
.protocol_error()
.code;
Ok(DemoReport {
object_name: object.name,
object_reference: object.r#ref,
object_validator: object.validator,
published_interfaces: object.interfaces,
catalog_interface_reference: catalog.reference().as_str().to_owned(),
audit_interface_reference: audit.reference().as_str().to_owned(),
catalog_descriptor_format: catalog_observation.descriptor.format,
audit_descriptor_format: audit_observation.descriptor.format,
published,
rejected,
catalog_status,
audit_status,
host_failure: host_error.kind(),
protocol_failure,
permission_failure,
validator_failure,
interface_mismatch,
interface_not_found,
precedence_failure,
})
}
fn ensure_kind(error: &wip_host::CallError, expected: CallErrorKind) -> Result<(), io::Error> {
if error.kind() == expected {
Ok(())
} else {
Err(io::Error::other(format!(
"unexpected host failure kind: {:?}",
error.kind()
)))
}
}
fn dispatch_publish(
provider: &ApplicationProvider,
catalog: &InterfaceRef,
request_value: PublishRequest,
) -> Result<PublishOutcome, Box<dyn Error>> {
let encoded_request = request_value.encode()?;
let result = provider.call_operation(request(
OBJECT_PATH,
catalog,
Some(CATALOG_VALIDATOR),
"publish",
BTreeMap::from([("request".into(), encoded_request)]),
))?;
Ok(PublishOutcome::decode(result.result)?)
}
fn dispatch_string(
provider: &ApplicationProvider,
request: CallRequest,
) -> Result<String, Box<dyn Error>> {
let result = provider.call_operation(request)?;
Ok(String::decode(result.result)?)
}
fn request(
path: &str,
interface: &InterfaceRef,
interface_validator: Option<&[u8]>,
operation: &str,
arguments: BTreeMap<String, Value>,
) -> CallRequest {
CallRequest {
target: Target {
path: path.into(),
validator: Some(OBJECT_VALIDATOR.to_vec()),
},
interface: InterfaceTarget {
reference: interface.as_str().to_owned(),
validator: interface_validator.map(<[u8]>::to_vec),
},
operation: operation.into(),
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))
}
fn authorize(
&self,
_: &str,
_: &ApplicationService,
_: &InterfaceRef,
operation: &str,
) -> bool {
operation != "restricted_status"
}
}
struct ApplicationService {
object_validator: Validator,
backend_connection: String,
}
impl ApplicationService {
fn new() -> Self {
Self {
object_validator: Validator::new(OBJECT_VALIDATOR),
backend_connection: "private://article-database".into(),
}
}
}
impl WipObject for ApplicationService {
fn name(&self) -> &str {
"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> {
Some(&self.object_validator)
}
fn requires_validator(&self, _: &InterfaceRef, _: &str) -> bool {
true
}
}
impl Catalog for ApplicationService {
fn publish(&self, request: PublishRequest) -> wip::OperationResult<PublishOutcome> {
if request.title.trim().is_empty() {
Ok(PublishOutcome::Rejected("a title is required".into()))
} else {
Ok(PublishOutcome::Published("article-1".into()))
}
}
fn status(&self) -> wip::OperationResult<String> {
Ok("catalog-ready".into())
}
fn backend_status(&self) -> wip::OperationResult<String> {
if self.backend_connection.starts_with("private://") {
Err(wip::HostError::new("backend unavailable"))
} else {
Ok("backend-ready".into())
}
}
fn restricted_status(&self) -> wip::OperationResult<String> {
Ok("authorization hook should prevent this call".into())
}
}
impl Audit for ApplicationService {
fn status(&self) -> wip::OperationResult<String> {
Ok("audit-ready".into())
}
}
impl Diagnostics for ApplicationService {
fn diagnostics(&self) -> wip::OperationResult<String> {
Ok("host-private".into())
}
}
@@ -0,0 +1,236 @@
use std::collections::BTreeMap;
use wip_host::CallErrorKind;
use wip_interface::{Entry, JsonMember, JsonValue, WipType};
use wip_protocol::{INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, ProtocolErrorCode, Value};
use wip_public_api_example::{CatalogDefinition, PublishOutcome, PublishRequest, Visibility, run};
#[test]
fn public_crates_support_the_complete_qualified_end_to_end_scenario() {
let report = run().unwrap();
assert_eq!(report.object_name, "article-service");
assert_eq!(
report.object_reference.as_deref(),
Some("article-service-instance")
);
assert_eq!(
report.object_validator.as_deref(),
Some(b"object-v1".as_slice())
);
assert_eq!(
report.published_interfaces,
[
report.catalog_interface_reference.clone(),
report.audit_interface_reference.clone(),
],
"interfaces must retain explicit publication order"
);
assert_ne!(
report.catalog_interface_reference, report.audit_interface_reference,
"interfaces form an ordered set of distinct opaque references"
);
assert_eq!(report.catalog_descriptor_format, INTERFACE_FORMAT_V1);
assert_eq!(report.audit_descriptor_format, INTERFACE_FORMAT_V1);
assert_eq!(
report.published,
PublishOutcome::Published("article-1".into())
);
assert_eq!(
report.rejected,
PublishOutcome::Rejected("a title is required".into())
);
assert_eq!(report.catalog_status, "catalog-ready");
assert_eq!(report.audit_status, "audit-ready");
}
#[test]
fn domain_host_permission_and_validator_failures_remain_distinct() {
let report = run().unwrap();
assert!(matches!(report.rejected, PublishOutcome::Rejected(_)));
assert_eq!(report.host_failure, CallErrorKind::Implementation);
assert_eq!(report.protocol_failure.code, ProtocolErrorCode::Internal);
assert_eq!(
report.permission_failure,
ProtocolErrorCode::PermissionDenied
);
assert_eq!(
report.validator_failure,
ProtocolErrorCode::ValidatorMismatch,
"validator recovery signals must not collapse to InvalidRequest"
);
}
#[test]
fn interface_mismatch_is_not_interface_not_found() {
let report = run().unwrap();
assert_eq!(
report.interface_mismatch,
ProtocolErrorCode::InterfaceMismatch
);
assert_eq!(
report.interface_not_found,
ProtocolErrorCode::InterfaceNotFound
);
assert_ne!(report.interface_mismatch, report.interface_not_found);
}
#[test]
fn invalid_path_keeps_canonical_failure_precedence() {
let report = run().unwrap();
assert_eq!(report.precedence_failure, ProtocolErrorCode::InvalidRequest);
assert_ne!(report.precedence_failure, report.validator_failure);
assert_ne!(report.precedence_failure, report.interface_mismatch);
}
#[test]
fn object_fields_keep_path_context_and_optional_ref_separate() {
// Listing every field makes this test fail to compile if a mandatory
// ObjectId is reintroduced into the canonical object DTO.
let object = Object {
name: "article".into(),
description: None,
interfaces: vec!["catalog-ref".into(), "audit-ref".into()],
r#ref: None,
validator: None,
};
object.validate_at_path("/library/article").unwrap();
assert_eq!(object.name, "article");
assert_eq!(object.r#ref, None);
assert_eq!(object.interfaces, ["catalog-ref", "audit-ref"]);
}
#[test]
fn interface_reference_is_observed_separately_from_descriptor_fields() {
// An exact destructuring pattern is compile-time coverage that the
// descriptor has no trait-name or opaque-reference `name` field.
let InterfaceDescriptor {
format,
documentation,
types,
operations,
} = CatalogDefinition::descriptor();
assert_eq!(format, INTERFACE_FORMAT_V1);
assert!(documentation.is_some());
assert!(!types.is_empty());
assert_eq!(
operations
.iter()
.map(|operation| operation.name.as_str())
.collect::<Vec<_>>(),
["publish", "status", "backend_status", "restricted_status"]
);
}
#[test]
fn application_record_enum_union_entry_and_json_use_schema_neutral_values() {
let request = PublishRequest {
title: "Canonical values".into(),
body: None,
source: Entry::new("/drafts/canonical-values").unwrap(),
visibility: Visibility::Public,
metadata: JsonValue::Object(vec![
JsonMember {
name: "tags".into(),
value: JsonValue::Array(vec![JsonValue::String("wip".into()), JsonValue::Null]),
},
JsonMember {
name: "ready".into(),
value: JsonValue::Boolean(true),
},
]),
};
let encoded = request.encode().unwrap();
assert_schema_neutral(&encoded);
let Value::Record(fields) = encoded else {
panic!("derived DTO record must use Value::Record")
};
assert_eq!(
fields.get("title"),
Some(&Value::String("Canonical values".into()))
);
assert!(!fields.contains_key("body"));
assert_eq!(
fields.get("source"),
Some(&Value::String("/drafts/canonical-values".into()))
);
assert_eq!(
fields.get("visibility"),
Some(&Value::String("Public".into()))
);
assert_eq!(
fields.get("metadata"),
Some(&Value::Record(BTreeMap::from([
("ready".into(), Value::Boolean(true)),
(
"tags".into(),
Value::List(vec![Value::String("wip".into()), Value::Unit])
),
])))
);
let outcome = PublishOutcome::Rejected("policy".into()).encode().unwrap();
assert_schema_neutral(&outcome);
assert_eq!(
outcome,
Value::Record(BTreeMap::from([
("$case".into(), Value::String("Rejected".into())),
("value".into(), Value::String("policy".into())),
]))
);
}
#[test]
fn entry_is_a_validated_canonical_absolute_path_string() {
let entry = Entry::new("/articles/one").unwrap();
assert_eq!(
entry.encode().unwrap(),
Value::String("/articles/one".into())
);
for invalid in [
"articles/one",
"/articles/",
"/articles//one",
"/articles/./one",
"/articles/../one",
] {
assert!(
Entry::new(invalid).is_err(),
"accepted invalid path {invalid}"
);
assert!(
Entry::decode(Value::String(invalid.into())).is_err(),
"decoded invalid path {invalid}"
);
}
}
fn assert_schema_neutral(value: &Value) {
// This exhaustive match is a compile-time regression guard: reintroducing
// semantic Entry/Enum/Union/Json variants makes the consumer non-exhaustive.
match value {
Value::Unit
| Value::Boolean(_)
| Value::Integer(_)
| Value::Number(_)
| Value::String(_)
| Value::Bytes(_) => {}
Value::Record(fields) => {
for value in fields.values() {
assert_schema_neutral(value);
}
}
Value::List(values) => {
for value in values {
assert_schema_neutral(value);
}
}
}
}
-617
View File
@@ -1,617 +0,0 @@
//! End-to-end use of the public WIP protocol, interface, and host APIs.
//!
//! This workspace package is deliberately separate from the three library
//! crates. It defines application DTOs and interfaces with `wip-interface`,
//! keeps its implementation private, publishes that implementation with
//! `wip-host`, and crosses the call boundary only with `wip-protocol` values.
//!
//! 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)]
use std::collections::BTreeMap;
use std::error::Error;
use std::io;
use wip_host::{
CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget, ObjectId,
PublishedObject, Validator, WipObject,
};
use wip_interface as wip;
use wip_interface::WipType;
use wip_protocol::ProtocolError;
const OBJECT_ID: &str = "article-service";
const OBJECT_VALIDATOR: &[u8] = b"object-v1";
const CATALOG_VALIDATOR: &[u8] = b"catalog-v1";
/// An application-owned request DTO mapped to a protocol record.
#[derive(Debug, Clone, PartialEq, Eq, wip::WipType)]
pub struct PublishRequest {
/// Title of the article to publish.
pub title: String,
/// Optional article body; omission is distinct from JSON null.
pub body: Option<String>,
}
/// 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)]
pub enum PublishOutcome {
/// Publication succeeded and returned its application identifier.
Published(String),
/// An application policy rejected the publication.
Rejected(String),
}
/// Article publication operations.
#[wip::interface]
pub trait Catalog {
/// Publishes an article or reports an application-domain rejection.
///
/// # Returns
/// The typed application outcome.
fn publish(
&self,
/// Application publication request.
request: PublishRequest,
) -> wip::OperationResult<PublishOutcome>;
/// Returns the catalog-qualified status.
///
/// # Returns
/// A catalog status label.
fn status(&self) -> wip::OperationResult<String>;
/// 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>;
}
/// Audit operations exposed by the same object.
#[wip::interface]
pub trait Audit {
/// 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>;
}
/// A registered but deliberately unexposed interface used to demonstrate that
/// Rust trait implementation does not imply protocol membership.
#[wip::interface]
pub trait Diagnostics {
/// Returns host-private diagnostics when the interface is explicitly exposed.
///
/// # Returns
/// A diagnostic label.
fn diagnostics(&self) -> wip::OperationResult<String>;
}
/// Observable outcomes from the complete public-API scenario.
#[derive(Debug, Clone, PartialEq)]
pub struct DemoReport {
/// Opaque interface references published by the object, in exposure order.
pub published_interfaces: Vec<String>,
/// Descriptor format observed for the catalog interface.
pub catalog_descriptor_format: String,
/// Descriptor format observed for the audit interface.
pub audit_descriptor_format: String,
/// Typed successful application result decoded from a validated `Value`.
pub published: PublishOutcome,
/// Typed application-domain rejection decoded from a validated `Value`.
pub rejected: PublishOutcome,
/// Result of the catalog-qualified `status` operation.
pub catalog_status: String,
/// Result of the audit-qualified `status` operation.
pub audit_status: String,
/// Stable failure kind produced by the deliberate host/backend failure.
pub host_failure: CallErrorKind,
/// A protocol-level failure built from the host failure.
pub protocol_failure: ProtocolError,
}
/// Runs the complete end-to-end example.
///
/// The function registers and observes generated descriptors, explicitly
/// exposes two interfaces on one host-private object, validates the resulting
/// protocol observation, dispatches interface-qualified calls using named
/// [`wip_protocol::Value`] arguments, decodes validated values back to
/// application types, and keeps a domain rejection distinct from a host
/// failure.
///
/// # Errors
///
/// Returns an error if registration, publication, observation validation,
/// dispatch, or application-value decoding fails unexpectedly.
pub fn run() -> Result<DemoReport, Box<dyn Error>> {
let mut registry = InterfaceRegistry::new();
let catalog = registry.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))?;
let audit = registry.register(AuditDefinition, None)?;
let _diagnostics = registry.register(DiagnosticsDefinition, None)?;
let publication = PublishedObject::new(ApplicationService::new())
.expose(&catalog)?
.expose(&audit)?;
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
.first()
.ok_or_else(|| io::Error::other("catalog interface was not published"))?;
let audit_wire_reference = object
.interfaces
.get(1)
.ok_or_else(|| io::Error::other("audit interface was not published"))?;
let catalog_reference = registry
.resolve(catalog_wire_reference)
.ok_or_else(|| io::Error::other("published catalog reference did not resolve"))?;
let audit_reference = registry
.resolve(audit_wire_reference)
.ok_or_else(|| io::Error::other("published audit reference did not resolve"))?;
let catalog_observation = registry
.observe(&catalog_reference)
.ok_or_else(|| io::Error::other("registered catalog descriptor was not observable"))?;
let audit_observation = registry
.observe(&audit_reference)
.ok_or_else(|| io::Error::other("registered audit descriptor was not observable"))?;
catalog_observation.descriptor.validate()?;
audit_observation.descriptor.validate()?;
let published = dispatch_publish(
&publication,
&catalog_observation.reference,
PublishRequest {
title: "Public APIs".into(),
body: Some("protocol values in, validated values out".into()),
},
)?;
let rejected = dispatch_publish(
&publication,
&catalog_observation.reference,
PublishRequest {
title: String::new(),
body: None,
},
)?;
let catalog_status = dispatch_string(
&publication,
request(
catalog_observation.reference.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
BTreeMap::new(),
),
"catalog status",
)?;
let audit_status = dispatch_string(
&publication,
request(
audit_observation.reference.clone(),
None,
"status",
BTreeMap::new(),
),
"audit status",
)?;
let host_error = match publication.dispatch(request(
catalog_observation.reference.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"backend_status",
BTreeMap::new(),
)) {
Ok(_) => {
return Err(io::Error::other(
"the deliberately unavailable example backend unexpectedly succeeded",
)
.into());
}
Err(error) => error,
};
if host_error.kind() != CallErrorKind::Implementation {
return Err(io::Error::other(format!(
"unexpected host failure kind: {:?}",
host_error.kind()
))
.into());
}
let protocol_failure = host_error.protocol_error();
Ok(DemoReport {
published_interfaces: object.interfaces,
catalog_descriptor_format: catalog_observation.descriptor.format,
audit_descriptor_format: audit_observation.descriptor.format,
published,
rejected,
catalog_status,
audit_status,
host_failure: host_error.kind(),
protocol_failure,
})
}
fn dispatch_publish(
publication: &PublishedObject<ApplicationService>,
catalog: &InterfaceRef,
request_value: PublishRequest,
) -> Result<PublishOutcome, Box<dyn Error>> {
let encoded_request = request_value.encode()?;
let result = publication.dispatch(request(
catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"publish",
BTreeMap::from([("request".into(), encoded_request)]),
))?;
let value = result.value;
Ok(PublishOutcome::decode(value)?)
}
fn dispatch_string(
publication: &PublishedObject<ApplicationService>,
request: CallRequest,
_context: &str,
) -> Result<String, Box<dyn Error>> {
let result = publication.dispatch(request)?;
let value = result.value;
Ok(String::decode(value)?)
}
fn request(
interface: InterfaceRef,
interface_validator: Option<Validator>,
operation: &str,
arguments: BTreeMap<String, wip_protocol::Value>,
) -> CallRequest {
CallRequest {
object: ObjectId(OBJECT_ID.into()),
object_validator: Some(Validator::new(OBJECT_VALIDATOR)),
interface: InterfaceTarget {
reference: interface,
validator: interface_validator,
},
operation: operation.into(),
arguments,
}
}
struct ApplicationService {
object_validator: Validator,
backend_connection: String,
}
impl ApplicationService {
fn new() -> Self {
Self {
object_validator: Validator::new(OBJECT_VALIDATOR),
backend_connection: "private://article-database".into(),
}
}
}
impl WipObject for ApplicationService {
fn id(&self) -> ObjectId {
ObjectId(OBJECT_ID.into())
}
fn validator(&self) -> Option<&Validator> {
Some(&self.object_validator)
}
fn requires_validator(&self) -> bool {
true
}
}
impl Catalog for ApplicationService {
fn publish(&self, request: PublishRequest) -> wip::OperationResult<PublishOutcome> {
if request.title.trim().is_empty() {
Ok(PublishOutcome::Rejected("a title is required".into()))
} else {
Ok(PublishOutcome::Published("article-1".into()))
}
}
fn status(&self) -> wip::OperationResult<String> {
Ok("catalog-ready".into())
}
fn backend_status(&self) -> wip::OperationResult<String> {
if self.backend_connection.starts_with("private://") {
Err(wip::HostError::new("backend unavailable"))
} else {
Ok("backend-ready".into())
}
}
}
impl Audit for ApplicationService {
fn status(&self) -> wip::OperationResult<String> {
Ok("audit-ready".into())
}
}
impl Diagnostics for ApplicationService {
fn diagnostics(&self) -> wip::OperationResult<String> {
Ok("host-private".into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use wip_host::CallError;
use wip_interface::{CodecError, TypeDeclaration, TypeExpr};
use wip_protocol::{ProtocolErrorCode, Value};
/// A deliberately inconsistent custom codec used to prove result validation.
pub struct InvalidValue;
impl WipType for InvalidValue {
fn type_expr() -> TypeExpr {
TypeExpr::String
}
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> {
Ok(Value::Integer(7))
}
fn decode(_: Value) -> Result<Self, CodecError> {
Ok(Self)
}
}
/// Test-only interface whose custom codec emits an invalid result.
#[wip::interface]
pub trait BrokenResult {
/// Returns a value that violates its declared protocol type.
fn broken_result(&self) -> wip::OperationResult<InvalidValue>;
}
impl BrokenResult for ApplicationService {
fn broken_result(&self) -> wip::OperationResult<InvalidValue> {
Ok(InvalidValue)
}
}
struct Fixture {
publication: PublishedObject<ApplicationService>,
catalog: InterfaceRef,
audit: InterfaceRef,
diagnostics: InterfaceRef,
}
fn fixture() -> Fixture {
let mut registry = InterfaceRegistry::new();
let catalog = registry
.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))
.unwrap();
let audit = registry.register(AuditDefinition, None).unwrap();
let diagnostics = registry.register(DiagnosticsDefinition, None).unwrap();
let publication = PublishedObject::new(ApplicationService::new())
.expose(&catalog)
.unwrap()
.expose(&audit)
.unwrap();
Fixture {
publication,
catalog: catalog.reference().clone(),
audit: audit.reference().clone(),
diagnostics: diagnostics.reference().clone(),
}
}
fn assert_failure(error: CallError, kind: CallErrorKind, code: ProtocolErrorCode) {
assert_eq!(error.kind(), kind);
assert_eq!(error.protocol_error().code, code);
}
#[test]
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",
BTreeMap::new(),
),
"catalog status",
)
.unwrap();
let audit = dispatch_string(
&fixture.publication,
request(fixture.audit.clone(), None, "status", BTreeMap::new()),
"audit status",
)
.unwrap();
assert_eq!(catalog, "catalog-ready");
assert_eq!(audit, "audit-ready");
let not_exposed = fixture
.publication
.dispatch(request(
fixture.diagnostics,
None,
"diagnostics",
BTreeMap::new(),
))
.unwrap_err();
assert_failure(
not_exposed,
CallErrorKind::InterfaceMismatch,
ProtocolErrorCode::InterfaceMismatch,
);
}
#[test]
fn every_host_precondition_and_dispatch_failure_is_typed() {
let fixture = fixture();
let mut wrong_object = request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
BTreeMap::new(),
);
wrong_object.object = ObjectId("other-object".into());
assert_failure(
fixture.publication.dispatch(wrong_object).unwrap_err(),
CallErrorKind::ObjectNotFound,
ProtocolErrorCode::NotFound,
);
let mut missing_object_validator = request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
BTreeMap::new(),
);
missing_object_validator.object_validator = None;
assert_failure(
fixture
.publication
.dispatch(missing_object_validator)
.unwrap_err(),
CallErrorKind::ValidatorRequired,
ProtocolErrorCode::InvalidRequest,
);
let mut stale_object_validator = request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
BTreeMap::new(),
);
stale_object_validator.object_validator = Some(Validator::new(b"stale-object"));
assert_failure(
fixture
.publication
.dispatch(stale_object_validator)
.unwrap_err(),
CallErrorKind::ValidatorMismatch,
ProtocolErrorCode::InvalidRequest,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
None,
"status",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::InterfaceValidatorRequired,
ProtocolErrorCode::InvalidRequest,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
Some(Validator::new(b"stale-interface")),
"status",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::InterfaceValidatorMismatch,
ProtocolErrorCode::InvalidRequest,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"missing",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::OperationNotFound,
ProtocolErrorCode::OperationNotFound,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"publish",
BTreeMap::from([("request".into(), Value::String("not a record".into()))]),
))
.unwrap_err(),
CallErrorKind::InvalidArguments,
ProtocolErrorCode::InvalidArguments,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog,
Some(Validator::new(CATALOG_VALIDATOR)),
"backend_status",
BTreeMap::new(),
))
.unwrap_err(),
CallErrorKind::Implementation,
ProtocolErrorCode::Internal,
);
}
#[test]
fn invalid_implementation_result_never_crosses_the_host_boundary() {
let mut registry = InterfaceRegistry::new();
let broken = registry.register(BrokenResultDefinition, None).unwrap();
let publication = PublishedObject::new(ApplicationService::new())
.expose(&broken)
.unwrap();
let error = publication
.dispatch(request(
broken.reference().clone(),
None,
"broken_result",
BTreeMap::new(),
))
.unwrap_err();
assert_failure(
error,
CallErrorKind::InvalidResult,
ProtocolErrorCode::Internal,
);
}
}
-24
View File
@@ -1,24 +0,0 @@
use wip_host::CallErrorKind;
use wip_protocol::{INTERFACE_FORMAT_V1, ProtocolErrorCode};
use wip_public_api_example::{PublishOutcome, run};
#[test]
fn public_crates_support_the_complete_end_to_end_scenario() {
let report = run().unwrap();
assert_eq!(report.published_interfaces.len(), 2);
assert_eq!(report.catalog_descriptor_format, INTERFACE_FORMAT_V1);
assert_eq!(report.audit_descriptor_format, INTERFACE_FORMAT_V1);
assert_eq!(
report.published,
PublishOutcome::Published("article-1".into())
);
assert_eq!(
report.rejected,
PublishOutcome::Rejected("a title is required".into())
);
assert_eq!(report.catalog_status, "catalog-ready");
assert_eq!(report.audit_status, "audit-ready");
assert_eq!(report.host_failure, CallErrorKind::Implementation);
assert_eq!(report.protocol_failure.code, ProtocolErrorCode::Internal);
}