Compare commits

..
26 changed files with 1960 additions and 879 deletions
Generated
+3 -1
View File
@@ -270,9 +270,11 @@ name = "wip-protocol"
version = "0.1.0"
[[package]]
name = "wip-rs"
name = "wip-public-api-example"
version = "0.1.0"
dependencies = [
"wip-host",
"wip-interface",
"wip-protocol",
]
+6 -1
View File
@@ -9,9 +9,14 @@ members = [
resolver = "3"
[package]
name = "wip-rs"
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" }
+148 -30
View File
@@ -2,6 +2,66 @@
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.
## Crate graph and responsibilities
```text
wip-public-api-example (workspace-only executable, integration tests, doctest)
├── wip-protocol logical declarations, values, calls, and validation
├── wip-interface application DTO codecs and interface authoring facade
│ ├── wip-protocol
│ └── wip-interface-macros proc-macro implementation detail
└── wip-host descriptor registry, publication, and qualified dispatch
├── wip-interface
└── wip-protocol
```
| Crate | Responsibility | Does not own |
| --- | --- | --- |
| `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. |
| `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. |
| `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. |
| `wip-host` | Opaque interface registration, explicit object membership, validator checks, and interface-qualified dispatch against one host-owned instance. | Backend atomicity, global object lookup, or network serving. |
| `wip-public-api-example` | An independently compiled consumer of only the three public crates. Its DTOs and traits are application-owned; its implementation and backend handle remain host-private. | Reusable library API or a production server. |
The boundary is intentional: protocol types describe what can cross a
transport; application DTOs opt into that model with `WipType`; a host-private
implementation owns credentials, handles, and backend state; and a future
transport maps its own routes/envelopes to `CallOperationRequest` and
`CallOperationResponse` without placing transport-specific types in descriptors
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:
1. public application DTOs derived as `WipType` records and unions;
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;
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.
Run it inside the development shell:
```console
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.
## Interface authoring
Applications depend on `wip-interface`, normally imported as `wip`:
@@ -30,42 +90,69 @@ pub trait Messenger {
```
The `interface` attribute and `WipType` derive are re-exported by the facade;
users do not import `wip-interface-macros` directly. The generated
`MessengerDefinition` constructs an `InterfaceDescriptor`, decodes named
arguments, encodes results, validates both boundaries, and provides typed
dispatch for any `T: Messenger`.
users do not import `wip-interface-macros` or generated private support symbols.
The generated `MessengerDefinition` constructs an `InterfaceDescriptor`,
decodes named arguments, encodes results, validates both boundaries, and
provides typed dispatch for any `T: Messenger`.
### Type mapping
### Supported Rust syntax
A supported interface is a public, non-generic trait containing methods with:
- exactly an immutable `&self` receiver;
- simple named, owned parameters;
- an explicit `wip::OperationResult<T>` return;
- `()` only as the application return when no value is produced; and
- direct `Option<T>` only for an omittable parameter.
`WipType` derives support named-field structs, all-unit enums, and unions whose
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,
unsafe, extern, variadic, generic, default-bodied, or non-`&self` methods;
borrowed/custom-lifetime values; tuple/unit/generic structs; Rust unions;
named-field/multi-field enum variants; explicit discriminants; nested `Option`; and
`Option` in returns, collections, entries, or union payloads. These diagnostics
are compile-fail tested under `crates/wip-interface/tests/ui`.
### Rust type mapping
| Rust | Protocol |
| --- | --- |
| `()` as an operation result | no returned value |
| `()` | unit / `Value::Unit` |
| `bool` | boolean |
| integer primitives | JavaScript-lossless integer |
| `f32`, `f64` | finite number |
| `String` | string |
| `wip::Bytes` | bytes |
| `wip::wip_protocol::JsonValue` | JSON |
| `wip::Entry<K, V>` | entry |
| `wip::JsonValue` | JSON interpreted over the schema-neutral `Value` tree |
| `wip::Entry` | canonical absolute Worldspace path encoded as a string |
| `Vec<T>` | list |
| named-field `#[derive(wip::WipType)]` struct | named record |
| all-unit `#[derive(wip::WipType)]` enum | named enum |
| all-newtype `#[derive(wip::WipType)]` enum | named tagged union |
| all-unit `#[derive(wip::WipType)]` enum | named enum encoded as a string |
| mixed unit/newtype or all-newtype enum | named union encoded as a discriminator record |
`Option<T>` is accepted only for a direct DTO field or operation parameter and
means that the named value may be omitted. It is not protocol null. Domain
failures are modeled in the application result (usually a derived union) and
returned through `Ok`; `wip::HostError` is only for host/runtime failures.
`Option<T>` means that a direct DTO field or operation parameter may be
omitted; it is not protocol null. JSON null remains available inside
`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`
section documents the return declaration; text before that section documents
the operation itself.
The supported interface slice is deliberately non-generic: public traits,
`&self`, named parameters, and `OperationResult<T>`. Generic traits or methods,
associated items, custom lifetimes, inheritance, default method bodies, and
other receiver forms are rejected at their source spans.
## Host publication and dispatch
Applications use `wip-host` to register generated definitions and explicitly
@@ -83,17 +170,48 @@ let protocol_object = published.protocol_object();
`expose` compiles only when the object implements the generated definition's
interface trait. Implementing a trait does not expose it automatically, empty
interface sets are valid, insertion order is preserved, and duplicate
references are rejected. Registry references are opaque; descriptor names and
Rust type names are not used as wire identity.
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
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
Enter the dev shell and run the acceptance commands exactly:
```console
nix develop
cargo fmt --all -- --check
cargo check --workspace --all-targets
cargo test --workspace --all-targets
cargo clippy --workspace --all-targets -- -D warnings
```
`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.
+250 -165
View File
@@ -3,35 +3,31 @@
//! # 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)]
use std::any::TypeId;
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::error::Error;
use std::fmt::{self, Display, Formatter};
use std::marker::PhantomData;
@@ -41,9 +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, NamedValue, Object, ObjectId, OperationCall, OperationTarget,
ProtocolError, ProtocolErrorCategory, ValidationError, Value,
FetchInterfaceResponse, InterfaceDescriptor, Object, ProtocolError, ProtocolErrorCode,
ValidationError, Value,
};
/// 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)]
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.
@@ -122,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,
@@ -157,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's protocol `name` to the assigned opaque reference so it
/// corresponds directly to [`Object::interfaces`].
pub fn register<D>(
&mut self,
_definition: D,
@@ -176,7 +181,7 @@ impl InterfaceRegistry {
return Err(RegistrationError::AlreadyRegistered);
}
let mut descriptor = D::descriptor();
let descriptor = D::descriptor();
descriptor
.validate()
.map_err(RegistrationError::InvalidDescriptor)?;
@@ -185,19 +190,14 @@ impl InterfaceRegistry {
"wip-interface:{}:{}",
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
.next_reference
.checked_add(1)
.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));
@@ -208,17 +208,41 @@ impl InterfaceRegistry {
})
}
/// Observes the descriptor and optional validator for an opaque reference.
/// Resolves a transport-provided opaque reference string in this registry.
#[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 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.
@@ -260,24 +284,37 @@ 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
}
}
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> {
artifact: Arc<InterfaceArtifact>,
@@ -287,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>>,
@@ -335,56 +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 object = Object {
id: self.implementation.id(),
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(),
};
debug_assert!(object.validate().is_ok());
object
r#ref: self.implementation.reference().map(str::to_owned),
validator: self
.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() {
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",
@@ -393,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(
@@ -401,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",
@@ -423,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
.artifact
.descriptor
.validate_call(&OperationCall {
target: OperationTarget {
object: request.object.clone(),
interface: bound.artifact.descriptor.name.clone(),
operation: request.operation.clone(),
},
arguments: request.arguments.clone(),
})
.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)?;
bound
.artifact
.descriptor
.validate_return(&request.operation, value.as_ref())
.validate_result(&request.operation, &value)
.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()),
})
}
@@ -458,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 {
@@ -479,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: 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.
#[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.
@@ -525,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,
@@ -533,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,
}
@@ -588,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 => ProtocolErrorCategory::ObjectNotFound,
CallErrorKind::InterfaceMismatch => ProtocolErrorCategory::InterfaceNotFound,
CallErrorKind::OperationNotFound => ProtocolErrorCategory::OperationNotFound,
CallErrorKind::InvalidArguments => ProtocolErrorCategory::InvalidArguments,
CallErrorKind::ValidatorRequired
| CallErrorKind::ValidatorMismatch
| CallErrorKind::InterfaceValidatorRequired
| CallErrorKind::InterfaceValidatorMismatch => ProtocolErrorCategory::InvalidRequest,
CallErrorKind::InvalidResult | CallErrorKind::Implementation => {
ProtocolErrorCategory::Internal
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::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 {
category,
code,
message: self.message.clone(),
}
}
+482 -321
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::{
CallErrorKind, CallRequest, InterfaceRegistry, InterfaceTarget, PublishedObject, Validator,
WipObject,
CallError, CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget,
ObjectProvider, PublishedObject, RegisteredInterface, Target, Validator, WipObject,
};
use wip_interface::{
CodecError, HostError, NamedValue, OperationResult, TypeDeclaration, TypeExpr, Value, WipType,
interface,
CodecError, HostError, OperationResult, TypeDeclaration, TypeExpr, Value, WipType, interface,
};
use wip_protocol::{ObjectId, ProtocolErrorCategory};
use wip_protocol::ProtocolErrorCode;
#[interface]
pub trait Reader {
@@ -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,361 +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: Vec<NamedValue>,
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");
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());
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(),
]
);
wip_protocol::InterfaceObservation {
object: object.clone(),
descriptors: vec![
registry.observe(writer.reference()).unwrap().descriptor,
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",
vec![],
))
.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);
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",
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) {
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",
vec![],
))
.unwrap_err();
assert_eq!(missing.kind(), CallErrorKind::OperationNotFound);
assert_eq!(
missing.protocol_error().category,
ProtocolErrorCategory::OperationNotFound
assert!(
error
.to_string()
.contains(provider.reader.reference().as_str())
);
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]
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",
vec![],
))
.unwrap_err();
assert_eq!(error.kind(), CallErrorKind::InvalidResult);
assert_eq!(
error.protocol_error().category,
ProtocolErrorCategory::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(),
provider.writer.reference(),
None,
"label",
vec![],
))
.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![],
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",
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(),
provider.reader.reference(),
Some(b"schema-v1"),
"label",
vec![],
BTreeMap::new(),
))
.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]
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",
vec![],
))
.unwrap_err();
assert_eq!(error.kind(), CallErrorKind::Implementation);
assert_eq!(
error.protocol_error().category,
ProtocolErrorCategory::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,6 +1,5 @@
use wip_host::{InterfaceRegistry, PublishedObject, WipObject};
use wip_interface::{OperationResult, interface};
use wip_protocol::ObjectId;
#[interface]
pub trait Search {
@@ -10,8 +9,8 @@ pub trait Search {
struct Object;
impl WipObject for Object {
fn id(&self) -> ObjectId {
ObjectId("object".into())
fn name(&self) -> &str {
"object"
}
}
@@ -1,27 +1,27 @@
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
| |
| required by a bound introduced by this call
|
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
--> 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
|
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`
--> src/lib.rs
+195 -113
View File
@@ -1,4 +1,10 @@
//! Procedural macros backing the public `wip-interface` authoring facade.
//!
//! This is an implementation crate. Applications should depend on
//! `wip-interface`, which re-exports both macros alongside the traits and types
//! used by their generated code.
#![deny(missing_docs)]
use std::collections::HashSet;
@@ -162,9 +168,11 @@ fn expand_interface(attribute: TokenStream2, item: ItemTrait) -> syn::Result<Tok
#(#declaration_collectors)*
::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,
declarations,
types: declarations,
operations: ::std::vec![#(#operation_declarations),*],
}
}
@@ -174,9 +182,12 @@ fn expand_interface(attribute: TokenStream2, item: ItemTrait) -> syn::Result<Tok
fn dispatch(
implementation: &T,
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::option::Option<::wip_interface::wip_protocol::Value>,
::wip_interface::wip_protocol::Value,
::wip_interface::DispatchError,
> {
let descriptor = Self::descriptor();
@@ -201,9 +212,12 @@ fn expand_interface(attribute: TokenStream2, item: ItemTrait) -> syn::Result<Tok
pub fn dispatch<T: #trait_ident>(
implementation: &T,
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::option::Option<::wip_interface::wip_protocol::Value>,
::wip_interface::wip_protocol::Value,
::wip_interface::DispatchError,
> {
<Self as ::wip_interface::InterfaceImplementation<T>>::dispatch(
@@ -470,13 +484,18 @@ fn parse_operation_result(output: &ReturnType) -> syn::Result<Type> {
_ => None,
});
let result = types.next().cloned();
if arguments.args.len() != 1 || result.is_none() || types.next().is_some() {
if arguments.args.len() != 1 || types.next().is_some() {
return Err(Error::new_spanned(
arguments,
"`OperationResult` must have exactly one application result type",
));
}
let result = result.expect("checked above");
let result = result.ok_or_else(|| {
Error::new_spanned(
arguments,
"`OperationResult` must have exactly one application result type",
)
})?;
if direct_option(&result)?.is_some() || contains_option(&result) {
return Err(Error::new_spanned(
result,
@@ -490,8 +509,13 @@ fn argument_item(operation: &Operation) -> TokenStream2 {
let arguments_ident = &operation.arguments_ident;
let fields = operation.parameters.iter().map(|parameter| {
let ident = &parameter.ident;
let name = &parameter.name;
let ty = parameter_rust_type(parameter);
quote!(pub #ident: #ty)
let documentation = format!("Decoded `{name}` operation argument.");
quote!(
#[doc = #documentation]
pub #ident: #ty
)
});
let decoders = operation.parameters.iter().map(|parameter| {
let ident = &parameter.ident;
@@ -507,12 +531,11 @@ fn argument_item(operation: &Operation) -> TokenStream2 {
let encode_result = if operation.unit_result {
quote! {
let () = result;
::std::result::Result::Ok(::std::option::Option::None)
::std::result::Result::Ok(::wip_interface::wip_protocol::Value::Unit)
}
} else {
quote! {
<#result_ty as ::wip_interface::WipType>::encode(result)
.map(::std::option::Option::Some)
}
};
@@ -525,9 +548,12 @@ fn argument_item(operation: &Operation) -> TokenStream2 {
impl #arguments_ident {
/// Decodes named logical values into typed operation arguments.
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> {
let mut decoder = ::wip_interface::__private::RecordDecoder::new(arguments)?;
let mut decoder = ::wip_interface::__private::RecordDecoder::new(arguments);
let decoded = Self {
#(#decoders,)*
};
@@ -539,7 +565,7 @@ fn argument_item(operation: &Operation) -> TokenStream2 {
pub fn encode_result(
result: #result_ty,
) -> ::std::result::Result<
::std::option::Option<::wip_interface::wip_protocol::Value>,
::wip_interface::wip_protocol::Value,
::wip_interface::CodecError,
> {
#encode_result
@@ -559,22 +585,18 @@ fn operation_declaration(operation: &Operation) -> TokenStream2 {
quote! {
::wip_interface::wip_protocol::ParameterDeclaration {
name: ::std::string::String::from(#parameter_name),
required: !#optional,
documentation: #parameter_doc,
ty: <#ty as ::wip_interface::WipType>::type_expr(),
optional: #optional,
r#type: <#ty as ::wip_interface::WipType>::type_expr(),
}
}
});
let returns = if operation.unit_result {
quote!(::std::option::Option::None)
} else {
let result_ty = &operation.result_ty;
let return_doc = documentation_tokens(operation.return_documentation.as_ref());
quote! {
::std::option::Option::Some(::wip_interface::wip_protocol::ReturnDeclaration {
documentation: #return_doc,
ty: <#result_ty as ::wip_interface::WipType>::type_expr(),
})
let result_ty = &operation.result_ty;
let return_doc = documentation_tokens(operation.return_documentation.as_ref());
let returns = quote! {
::wip_interface::wip_protocol::ReturnDeclaration {
documentation: #return_doc,
r#type: <#result_ty as ::wip_interface::WipType>::type_expr(),
}
};
@@ -630,7 +652,7 @@ fn dispatch_arm(operation: &Operation) -> TokenStream2 {
let encoded = #arguments_ident::encode_result(result)
.map_err(::wip_interface::DispatchError::Encode)?;
descriptor
.validate_return(#operation_name, encoded.as_ref())
.validate_result(#operation_name, &encoded)
.map_err(::wip_interface::DispatchError::InvalidResult)?;
::std::result::Result::Ok(encoded)
}
@@ -698,7 +720,12 @@ fn derive_record(
let mut parsed_fields = Vec::new();
let mut names = HashSet::new();
for field in fields {
let field_ident = field.ident.clone().expect("named fields have identifiers");
let Some(field_ident) = field.ident.clone() else {
return Err(Error::new_spanned(
field,
"`WipType` record fields must be named",
));
};
let name = source_name(&field_ident);
if !names.insert(name.clone()) {
return Err(Error::new(
@@ -726,9 +753,9 @@ fn derive_record(
quote! {
::wip_interface::wip_protocol::FieldDeclaration {
name: ::std::string::String::from(#name),
required: !#optional,
documentation: #documentation,
ty: <#ty as ::wip_interface::WipType>::type_expr(),
optional: #optional,
r#type: <#ty as ::wip_interface::WipType>::type_expr(),
}
}
});
@@ -744,18 +771,18 @@ fn derive_record(
if field.optional {
quote! {
if let ::std::option::Option::Some(value) = #field_ident {
fields.push(::wip_interface::wip_protocol::NamedValue {
name: ::std::string::String::from(#field_name),
value: <#ty as ::wip_interface::WipType>::encode(value)?,
});
fields.insert(
::std::string::String::from(#field_name),
<#ty as ::wip_interface::WipType>::encode(value)?,
);
}
}
} else {
quote! {
fields.push(::wip_interface::wip_protocol::NamedValue {
name: ::std::string::String::from(#field_name),
value: <#ty as ::wip_interface::WipType>::encode(#field_ident)?,
});
fields.insert(
::std::string::String::from(#field_name),
<#ty as ::wip_interface::WipType>::encode(#field_ident)?,
);
}
}
});
@@ -773,9 +800,9 @@ fn derive_record(
Ok(quote! {
impl ::wip_interface::WipType for #ident {
fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr {
::wip_interface::wip_protocol::TypeExpr::Named(
::std::string::String::from(#type_name),
)
::wip_interface::wip_protocol::TypeExpr::Named {
name: ::std::string::String::from(#type_name),
}
}
fn collect_declarations(
@@ -783,13 +810,13 @@ fn derive_record(
::wip_interface::wip_protocol::TypeDeclaration,
>,
) {
let declaration = ::wip_interface::wip_protocol::TypeDeclaration::Record(
::wip_interface::wip_protocol::RecordDeclaration {
name: ::std::string::String::from(#type_name),
documentation: #declaration_doc,
let declaration = ::wip_interface::wip_protocol::TypeDeclaration {
name: ::std::string::String::from(#type_name),
documentation: #declaration_doc,
definition: ::wip_interface::wip_protocol::TypeExpr::Record {
fields: ::std::vec![#(#field_declarations),*],
},
);
};
if ::wip_interface::__private::register_declaration(declarations, declaration) {
#(#dependency_collectors)*
}
@@ -802,7 +829,7 @@ fn derive_record(
::wip_interface::CodecError,
> {
let Self { #(#destructured_fields),* } = self;
let mut fields = ::std::vec::Vec::new();
let mut fields = ::std::collections::BTreeMap::new();
#(#encoders)*
::std::result::Result::Ok(
::wip_interface::wip_protocol::Value::Record(fields),
@@ -814,7 +841,7 @@ fn derive_record(
) -> ::std::result::Result<Self, ::wip_interface::CodecError> {
match value {
::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 {
#(#decoders,)*
};
@@ -849,14 +876,14 @@ fn derive_enum(
.variants
.iter()
.all(|variant| matches!(variant.fields, Fields::Unit));
let all_payload = !data.variants.is_empty()
&& data.variants.iter().all(|variant| {
matches!(&variant.fields, Fields::Unnamed(fields) if fields.unnamed.len() == 1)
});
let valid_union = data.variants.iter().all(|variant| {
matches!(variant.fields, Fields::Unit)
|| matches!(&variant.fields, Fields::Unnamed(fields) if fields.unnamed.len() == 1)
});
if all_unit {
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)
} else {
let span = data
@@ -869,7 +896,7 @@ fn derive_enum(
.map_or_else(|| data.enum_token.span, Spanned::span);
Err(Error::new(
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",
))
}
}
@@ -885,7 +912,7 @@ fn derive_unit_enum(
let name = source_name(&variant.ident);
let documentation = documentation_tokens(documentation(&variant.attrs).as_ref());
quote! {
::wip_interface::wip_protocol::EnumVariantDeclaration {
::wip_interface::wip_protocol::EnumCase {
name: ::std::string::String::from(#name),
documentation: #documentation,
}
@@ -905,9 +932,9 @@ fn derive_unit_enum(
Ok(quote! {
impl ::wip_interface::WipType for #ident {
fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr {
::wip_interface::wip_protocol::TypeExpr::Named(
::std::string::String::from(#type_name),
)
::wip_interface::wip_protocol::TypeExpr::Named {
name: ::std::string::String::from(#type_name),
}
}
fn collect_declarations(
@@ -915,13 +942,13 @@ fn derive_unit_enum(
::wip_interface::wip_protocol::TypeDeclaration,
>,
) {
let declaration = ::wip_interface::wip_protocol::TypeDeclaration::Enum(
::wip_interface::wip_protocol::EnumDeclaration {
name: ::std::string::String::from(#type_name),
documentation: #declaration_doc,
variants: ::std::vec![#(#declarations),*],
let declaration = ::wip_interface::wip_protocol::TypeDeclaration {
name: ::std::string::String::from(#type_name),
documentation: #declaration_doc,
definition: ::wip_interface::wip_protocol::TypeExpr::Enum {
cases: ::std::vec![#(#declarations),*],
},
);
};
let _ = ::wip_interface::__private::register_declaration(
declarations,
declaration,
@@ -938,7 +965,7 @@ fn derive_unit_enum(
#(#encoders,)*
};
::std::result::Result::Ok(
::wip_interface::wip_protocol::Value::Enum(
::wip_interface::wip_protocol::Value::String(
::std::string::String::from(variant),
),
)
@@ -948,7 +975,7 @@ fn derive_unit_enum(
value: ::wip_interface::wip_protocol::Value,
) -> ::std::result::Result<Self, ::wip_interface::CodecError> {
match value {
::wip_interface::wip_protocol::Value::Enum(variant) => {
::wip_interface::wip_protocol::Value::String(variant) => {
match variant.as_str() {
#(#decoders,)*
_ => ::std::result::Result::Err(
@@ -977,30 +1004,36 @@ fn derive_union_enum(
struct Variant<'a> {
ident: &'a Ident,
name: String,
ty: &'a Type,
payload: Option<&'a Type>,
documentation: Option<Documentation>,
}
let mut variants = Vec::new();
for variant in &data.variants {
let Fields::Unnamed(fields) = &variant.fields else {
unreachable!("enum shape was checked before union generation");
let payload = match &variant.fields {
Fields::Unit => None,
Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {
let field = fields.unnamed.first().expect("length checked");
if direct_option(&field.ty)?.is_some() || contains_option(&field.ty) {
return Err(Error::new_spanned(
&field.ty,
"`Option<T>` is only supported directly on DTO fields and operation parameters",
));
}
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",
));
}
};
let field = fields
.unnamed
.first()
.expect("union variants have one field");
if direct_option(&field.ty)?.is_some() || contains_option(&field.ty) {
return Err(Error::new_spanned(
&field.ty,
"`Option<T>` is only supported directly on DTO fields and operation parameters",
));
}
validate_value_type(&field.ty, "union variant payload")?;
variants.push(Variant {
ident: &variant.ident,
name: source_name(&variant.ident),
ty: &field.ty,
payload,
documentation: documentation(&variant.attrs),
});
}
@@ -1009,50 +1042,95 @@ fn derive_union_enum(
let declarations = variants.iter().map(|variant| {
let name = &variant.name;
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! {
::wip_interface::wip_protocol::UnionVariantDeclaration {
::wip_interface::wip_protocol::UnionCase {
name: ::std::string::String::from(#name),
documentation: #documentation,
ty: <#ty as ::wip_interface::WipType>::type_expr(),
payload: #payload,
}
}
});
let dependency_collectors = variants.iter().map(|variant| {
let ty = variant.ty;
quote!(<#ty as ::wip_interface::WipType>::collect_declarations(declarations);)
let dependency_collectors = variants.iter().filter_map(|variant| {
variant.payload.map(
|ty| quote!(<#ty as ::wip_interface::WipType>::collect_declarations(declarations);),
)
});
let encoders = variants.iter().map(|variant| {
let variant_ident = variant.ident;
let name = &variant.name;
let ty = variant.ty;
quote! {
Self::#variant_ident(value) => {
::wip_interface::wip_protocol::Value::Union {
variant: ::std::string::String::from(#name),
value: ::std::boxed::Box::new(
match variant.payload {
Some(ty) => quote! {
Self::#variant_ident(value) => {
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),
),
);
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 variant_ident = variant.ident;
let name = &variant.name;
let ty = variant.ty;
quote! {
#name => <#ty as ::wip_interface::WipType>::decode(*value)
.map(Self::#variant_ident)
match variant.payload {
Some(ty) => quote! {
#name => {
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! {
impl ::wip_interface::WipType for #ident {
fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr {
::wip_interface::wip_protocol::TypeExpr::Named(
::std::string::String::from(#type_name),
)
::wip_interface::wip_protocol::TypeExpr::Named {
name: ::std::string::String::from(#type_name),
}
}
fn collect_declarations(
@@ -1060,13 +1138,13 @@ fn derive_union_enum(
::wip_interface::wip_protocol::TypeDeclaration,
>,
) {
let declaration = ::wip_interface::wip_protocol::TypeDeclaration::Union(
::wip_interface::wip_protocol::UnionDeclaration {
name: ::std::string::String::from(#type_name),
documentation: #declaration_doc,
variants: ::std::vec![#(#declarations),*],
let declaration = ::wip_interface::wip_protocol::TypeDeclaration {
name: ::std::string::String::from(#type_name),
documentation: #declaration_doc,
definition: ::wip_interface::wip_protocol::TypeExpr::Union {
cases: ::std::vec![#(#declarations),*],
},
);
};
if ::wip_interface::__private::register_declaration(declarations, declaration) {
#(#dependency_collectors)*
}
@@ -1088,26 +1166,30 @@ fn derive_union_enum(
value: ::wip_interface::wip_protocol::Value,
) -> ::std::result::Result<Self, ::wip_interface::CodecError> {
match value {
::wip_interface::wip_protocol::Value::Union { variant, value } => {
match variant.as_str() {
::wip_interface::wip_protocol::Value::Record(fields) => {
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,)*
_ => ::std::result::Result::Err(
::wip_interface::__private::decode_unknown_variant(
#type_name,
variant,
case,
),
),
}
}
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)> {
if let Some(inner) = direct_option(ty)? {
if contains_option(&inner) {
+220 -104
View File
@@ -30,37 +30,63 @@
//!
//! | Rust type | Protocol type/value |
//! | --- | --- |
//! | `()` as an operation result | no return declaration / no value |
//! | `()` | `Unit` |
//! | `bool` | `Boolean` |
//! | signed and unsigned integer primitives | JavaScript-lossless `Integer` |
//! | `f32`, `f64` | finite `Number` |
//! | `String` | `String` |
//! | [`Bytes`] | `Bytes` |
//! | [`wip_protocol::JsonValue`] | `Json` |
//! | [`Entry<K, V>`] | `Entry<K, V>` |
//! | [`JsonValue`] | schema-neutral `Json` value tree |
//! | [`Entry`] | canonical absolute Worldspace path (`Entry`) |
//! | `Vec<T>` | `List<T>` |
//! | a named-field struct deriving [`WipType`] | named record |
//! | 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
//! parameter. It means omission, not a null value. A domain failure belongs in
//! an application return enum/union and is returned as `Ok(value)`.
//! [`HostError`] is reserved for host/runtime failure, so the required
//! [`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
//!
//! An interface is a public, non-generic trait whose operations use exactly
//! `&self`, simple named owned parameters, and an explicit
//! `OperationResult<T>`. `WipType` derives support named-field structs,
//! 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,
//! unsafe, extern, variadic, generic, default-bodied, or non-`&self` methods;
//! borrowed values and custom lifetimes; tuple, unit, or generic structs;
//! unsupported named-field or multi-field enum variants; explicit discriminants; Rust unions;
//! and `Option` anywhere except a direct DTO field or operation parameter.
//! Unsupported inputs produce source-located compile errors rather than
//! generating a partial interface.
#![deny(missing_docs)]
use std::collections::BTreeMap;
use std::error::Error;
use std::fmt::{self, Display, Formatter};
pub use wip_interface_macros::{WipType, interface};
pub use wip_protocol;
pub use wip_protocol::{
Documentation, EnumDeclaration, EnumVariantDeclaration, FieldDeclaration, InterfaceDescriptor,
JsonMember, JsonValue, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NamedValue, OperationDeclaration,
ParameterDeclaration, RecordDeclaration, ReturnDeclaration, TypeDeclaration, TypeExpr,
UnionDeclaration, UnionVariantDeclaration, ValidationError, Value,
Documentation, EnumCase, FieldDeclaration, InterfaceDescriptor, MAX_SAFE_INTEGER,
MIN_SAFE_INTEGER, OperationDeclaration, ParameterDeclaration, ReturnDeclaration,
TypeDeclaration, TypeExpr, UnionCase, ValidationError, Value,
};
/// Converts one Rust application type to and from its protocol representation.
@@ -103,8 +129,8 @@ pub trait InterfaceImplementation<T>: InterfaceDefinition {
fn dispatch(
implementation: &T,
operation: &str,
arguments: Vec<NamedValue>,
) -> Result<Option<Value>, DispatchError>;
arguments: BTreeMap<String, Value>,
) -> Result<Value, DispatchError>;
}
/// Result convention required for generated interface operations.
@@ -266,13 +292,101 @@ impl From<Bytes> for Vec<u8> {
}
}
/// A typed protocol key/value entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry<K, V> {
/// Entry key.
pub key: K,
/// Entry value.
pub value: V,
/// A canonical absolute path to an entry in the current Worldspace.
///
/// This replaces the pre-canonical `Entry<K, V>` key/value pair. Construct an
/// entry from a path such as `/articles/1`; application data associated with
/// that entry belongs in an ordinary record alongside this value.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
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 {
@@ -441,52 +555,87 @@ impl WipType for JsonValue {
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> {
validate_json(&self)?;
Ok(Value::Json(self))
encode_json(self)
}
fn decode(value: Value) -> Result<Self, CodecError> {
match value {
Value::Json(value) => {
validate_json(&value)?;
Ok(value)
decode_json(value)
}
}
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));
}
}
other => Err(CodecError::expected("json", &other)),
Ok(Value::Record(fields))
}
}
}
fn validate_json(value: &JsonValue) -> Result<(), CodecError> {
fn decode_json(value: Value) -> Result<JsonValue, CodecError> {
match value {
JsonValue::Integer(value) => ensure_safe_integer(*value),
JsonValue::Number(value) => ensure_finite(*value),
JsonValue::Array(values) => {
for (index, value) in values.iter().enumerate() {
validate_json(value).map_err(|error| error.at(index.to_string()))?;
}
Ok(())
Value::Unit => Ok(JsonValue::Null),
Value::Boolean(value) => Ok(JsonValue::Boolean(value)),
Value::Integer(value) => {
ensure_safe_integer(value)?;
Ok(JsonValue::Integer(value))
}
JsonValue::Object(members) => {
let mut names = std::collections::HashSet::new();
for member in members {
if !names.insert(member.name.as_str()) {
return Err(CodecError::new(format!(
"duplicate JSON member `{}`",
member.name
))
.at(member.name.clone()));
}
validate_json(&member.value).map_err(|error| error.at(member.name.clone()))?;
}
Ok(())
Value::Number(value) => {
ensure_finite(value)?;
Ok(JsonValue::Number(value))
}
JsonValue::Null | JsonValue::Boolean(_) | JsonValue::String(_) => Ok(()),
Value::String(value) => Ok(JsonValue::String(value)),
Value::Bytes(_) => Err(CodecError::new("bytes are not valid inside JSON")),
Value::List(values) => values
.into_iter()
.enumerate()
.map(|(index, value)| decode_json(value).map_err(|error| error.at(index.to_string())))
.collect::<Result<Vec<_>, _>>()
.map(JsonValue::Array),
Value::Record(fields) => fields
.into_iter()
.map(|(name, value)| {
decode_json(value)
.map(|value| JsonMember {
name: name.clone(),
value,
})
.map_err(|error| error.at(name))
})
.collect::<Result<Vec<_>, _>>()
.map(JsonValue::Object),
}
}
impl<T: WipType> WipType for Vec<T> {
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>) {
@@ -513,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 {
TypeExpr::Entry {
key: Box::new(K::type_expr()),
value: Box::new(V::type_expr()),
}
TypeExpr::Entry
}
fn collect_declarations(declarations: &mut Vec<TypeDeclaration>) {
K::collect_declarations(declarations);
V::collect_declarations(declarations);
}
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> {
Ok(Value::Entry {
key: Box::new(self.key.encode().map_err(|error| error.at("key"))?),
value: Box::new(self.value.encode().map_err(|error| error.at("value"))?),
})
validate_entry_path(&self.0)?;
Ok(Value::String(self.0))
}
fn decode(value: Value) -> Result<Self, CodecError> {
let Value::Entry { key, value } = value else {
return Err(CodecError::expected("entry", &value));
let Value::String(path) = value else {
return Err(CodecError::expected("entry path string", &value));
};
Ok(Self {
key: K::decode(*key).map_err(|error| error.at("key"))?,
value: V::decode(*value).map_err(|error| error.at("value"))?,
})
Self::new(path)
}
}
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.
///
/// These APIs are public only because macro expansions execute in the
@@ -551,12 +694,9 @@ impl<K: WipType, V: WipType> WipType for Entry<K, V> {
/// and are not intended for direct use.
#[doc(hidden)]
pub mod __private {
use std::collections::HashSet;
use std::collections::BTreeMap;
use wip_protocol::{
InterfaceDescriptor, NamedValue, ObjectId, OperationCall, OperationTarget, TypeDeclaration,
ValidationError, Value,
};
use wip_protocol::{InterfaceDescriptor, TypeDeclaration, ValidationError, Value};
use crate::{CodecError, Documentation, WipType};
@@ -591,16 +731,9 @@ pub mod __private {
pub fn validate_arguments(
descriptor: &InterfaceDescriptor,
operation: &str,
arguments: &[NamedValue],
arguments: &BTreeMap<String, Value>,
) -> Result<(), ValidationError> {
descriptor.validate_call(&OperationCall {
target: OperationTarget {
object: ObjectId("<typed-dispatch>".into()),
interface: descriptor.name.clone(),
operation: operation.into(),
},
arguments: arguments.to_vec(),
})
descriptor.validate_arguments(operation, arguments)
}
/// Builds a codec error for a generated structural mismatch.
@@ -618,22 +751,13 @@ pub mod __private {
/// Decoder for a protocol record or operation's named argument collection.
pub struct RecordDecoder {
fields: Vec<NamedValue>,
seen: HashSet<String>,
fields: BTreeMap<String, Value>,
}
impl RecordDecoder {
/// Starts decoding record fields or named operation arguments and
/// rejects duplicate names.
pub fn new(fields: Vec<NamedValue>) -> Result<Self, CodecError> {
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, seen })
/// Starts decoding record fields or named operation arguments.
pub fn new(fields: BTreeMap<String, Value>) -> Self {
Self { fields }
}
/// Decodes a required field.
@@ -654,23 +778,15 @@ pub mod __private {
/// Rejects any fields that were not consumed by generated code.
pub fn finish(self) -> Result<(), CodecError> {
if let Some(field) = self.fields.first() {
Err(CodecError::new(format!("unknown field `{}`", field.name))
.at(field.name.clone()))
if let Some(name) = self.fields.keys().next() {
Err(CodecError::new(format!("unknown field `{name}`")).at(name.clone()))
} else {
Ok(())
}
}
fn take(&mut self, name: &str) -> Option<Value> {
let index = self.fields.iter().position(|field| field.name == name)?;
Some(self.fields.remove(index).value)
}
/// Returns the set of names supplied to this decoder.
#[allow(dead_code)]
pub fn supplied_names(&self) -> &HashSet<String> {
&self.seen
self.fields.remove(name)
}
}
}
+112 -49
View File
@@ -1,8 +1,7 @@
use wip_interface::wip_protocol::{
EnumDeclaration, JsonMember, JsonValue, NamedValue, RecordDeclaration, TypeDeclaration,
TypeExpr, UnionDeclaration, Value,
};
use wip_interface::{Bytes, Entry, WipType};
use std::collections::BTreeMap;
use wip_interface::wip_protocol::{TypeExpr, UNION_CASE_FIELD, UNION_VALUE_FIELD, Value};
use wip_interface::{Bytes, Entry, JsonMember, JsonValue, WipType};
/// A request record.
#[derive(Debug, Clone, PartialEq, WipType)]
@@ -11,8 +10,8 @@ struct Request {
title: String,
/// Optional priority.
priority: Option<i32>,
/// Related entries.
related: Vec<Entry<String, bool>>,
/// Related Worldspace entries.
related: Vec<Entry>,
/// Opaque bytes.
body: Bytes,
/// Arbitrary metadata.
@@ -38,33 +37,53 @@ struct Receipt {
/// Application-level publication outcome.
#[derive(Debug, Clone, PartialEq, WipType)]
enum PublishOutcome {
/// Work has not completed and carries no payload.
Pending,
/// Application success.
Published(Receipt),
/// Domain rejection, not a host failure.
Rejected(String),
}
fn field<'a>(record: &'a BTreeMap<String, Value>, name: &str) -> &'a Value {
record
.get(name)
.unwrap_or_else(|| panic!("missing `{name}`"))
}
#[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 {
title: "hello".into(),
priority: None,
related: vec![Entry {
key: "/articles/1".into(),
value: true,
}],
related: vec![Entry::new("/articles/1").unwrap()],
body: Bytes(vec![1, 2, 3]),
metadata: JsonValue::Object(vec![JsonMember {
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 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);
let number = 42.5_f64;
@@ -77,92 +96,136 @@ fn supported_values_round_trip() {
f32::decode(narrow_number.encode().unwrap()).unwrap(),
narrow_number
);
}
#[test]
fn enums_are_strings_and_unions_are_discriminated_records() {
for color in [Color::Red, Color::Blue] {
let encoded = color.clone().encode().unwrap();
let decoded = Color::decode(encoded).unwrap();
assert_eq!(decoded, color);
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 [
PublishOutcome::Pending,
PublishOutcome::Published(Receipt { number: 7 }),
PublishOutcome::Rejected("policy".into()),
] {
let encoded = outcome.clone().encode().unwrap();
let decoded = PublishOutcome::decode(encoded).unwrap();
assert_eq!(decoded, outcome);
assert_eq!(PublishOutcome::decode(encoded).unwrap(), outcome);
}
}
#[test]
fn derived_declarations_include_docs_optionality_and_dependencies() {
fn derived_declarations_use_canonical_named_type_expressions() {
let mut declarations = Vec::new();
Request::collect_declarations(&mut declarations);
Color::collect_declarations(&mut declarations);
PublishOutcome::collect_declarations(&mut declarations);
let TypeDeclaration::Record(RecordDeclaration {
documentation,
fields,
..
}) = &declarations[0]
else {
let request = declarations
.iter()
.find(|declaration| declaration.name == "Request")
.unwrap();
assert_eq!(
request.documentation.as_ref().unwrap().summary,
"A request record."
);
let TypeExpr::Record { fields } = &request.definition else {
panic!("Request must be a record declaration")
};
assert_eq!(documentation.as_ref().unwrap().summary, "A request record.");
assert_eq!(
fields[0].documentation.as_ref().unwrap().summary,
"Required title."
);
assert!(!fields[0].optional);
assert!(fields[1].optional);
assert_eq!(fields[1].ty, TypeExpr::Integer);
assert!(fields[0].required);
assert!(!fields[1].required);
assert_eq!(fields[1].r#type, TypeExpr::Integer);
assert_eq!(
fields[2].ty,
TypeExpr::List(Box::new(TypeExpr::Entry {
key: Box::new(TypeExpr::String),
value: Box::new(TypeExpr::Boolean),
}))
fields[2].r#type,
TypeExpr::List {
items: Box::new(TypeExpr::Entry),
}
);
let color = declarations
.iter()
.find(|declaration| declaration.name() == "Color")
.find(|declaration| declaration.name == "Color")
.unwrap();
let TypeDeclaration::Enum(EnumDeclaration { variants, .. }) = color else {
let TypeExpr::Enum { cases } = &color.definition else {
panic!("Color must be an enum declaration")
};
assert_eq!(
variants[0].documentation.as_ref().unwrap().summary,
cases[0].documentation.as_ref().unwrap().summary,
"Warm red."
);
let outcome = declarations
.iter()
.find(|declaration| declaration.name() == "PublishOutcome")
.find(|declaration| declaration.name == "PublishOutcome")
.unwrap();
let TypeDeclaration::Union(UnionDeclaration { variants, .. }) = outcome else {
let TypeExpr::Union { cases } = &outcome.definition else {
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!(
declarations
.iter()
.any(|declaration| declaration.name() == "Receipt")
.any(|declaration| declaration.name == "Receipt")
);
}
#[test]
fn codecs_reject_invalid_boundaries() {
fn codecs_reject_invalid_boundaries_and_json_bytes() {
assert!(i64::MAX.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!(
Request::decode(Value::Record(vec![NamedValue {
name: "unknown".into(),
value: Value::String("value".into()),
}]))
Request::decode(Value::Record(BTreeMap::from([(
"unknown".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()
);
assert!(Color::decode(Value::Enum("Green".into())).is_err());
}
+49 -53
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};
/// Publication request.
@@ -36,7 +38,7 @@ pub trait Catalog {
note: Option<String>,
) -> OperationResult<PublishOutcome>;
/// Checks connectivity without returning an application value.
/// Checks connectivity without returning application data.
fn ping(&self) -> OperationResult<()>;
/// Demonstrates a host/runtime failure.
@@ -88,36 +90,29 @@ impl Catalog for RejectingCatalog {
}
}
fn named(name: &str, value: Value) -> NamedValue {
NamedValue {
name: name.into(),
value,
}
}
fn publish_arguments(with_note: bool) -> Vec<NamedValue> {
let mut arguments = vec![named(
"request",
fn publish_arguments(with_note: bool) -> BTreeMap<String, Value> {
let mut arguments = BTreeMap::from([(
"request".into(),
PublishRequest {
title: "example".into(),
}
.encode()
.unwrap(),
)];
)]);
if with_note {
arguments.push(named("note", Value::String("audit".into())));
arguments.insert("note".into(), Value::String("audit".into()));
}
arguments
}
#[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 second = CatalogDefinition::descriptor();
assert_eq!(first, second);
first.validate().unwrap();
assert_eq!(first.name, "Catalog");
assert_eq!(first.format, INTERFACE_FORMAT_V1);
assert_eq!(
first.documentation.as_ref().unwrap().summary,
"Catalog editing capability."
@@ -139,81 +134,82 @@ fn descriptor_is_generated_only_from_the_trait_declaration() {
.summary,
"Item to publish."
);
assert!(!publish.parameters[0].optional);
assert!(publish.parameters[1].optional);
assert!(publish.parameters[0].required);
assert!(!publish.parameters[1].required);
assert_eq!(
publish
.returns
.as_ref()
.unwrap()
.documentation
.as_ref()
.unwrap()
.summary,
publish.returns.documentation.as_ref().unwrap().summary,
"The application publication outcome."
);
assert_eq!(
publish.returns.as_ref().unwrap().ty,
TypeExpr::Named("PublishOutcome".into())
publish.returns.r#type,
TypeExpr::Named {
name: "PublishOutcome".into(),
}
);
assert!(first.operations[1].returns.is_none());
assert_eq!(first.operations[1].returns.r#type, TypeExpr::Unit);
assert!(
first
.declarations
.types
.iter()
.any(|declaration| matches!(declaration, TypeDeclaration::Record(_)))
.any(|declaration| matches!(declaration.definition, TypeExpr::Record { .. }))
);
assert!(
first
.declarations
.types
.iter()
.any(|declaration| matches!(declaration, TypeDeclaration::Union(_)))
.any(|declaration| matches!(declaration.definition, TypeExpr::Union { .. }))
);
// Both implementation types satisfy the same generated dispatch boundary;
// neither can affect the descriptor shape.
// Both implementation types satisfy one immutable generated definition;
// implementation selection cannot contribute fields to the descriptor.
fn accepts_definition<T: Catalog>(_: &T) {}
accepts_definition(&AcceptingCatalog);
accepts_definition(&RejectingCatalog);
assert_eq!(CatalogDefinition::descriptor(), first);
}
#[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();
assert_eq!(decoded.request.title, "example");
assert_eq!(decoded.note, None);
let encoded = CatalogPublishArguments::encode_result(PublishOutcome::Rejected("domain".into()))
.unwrap()
.unwrap();
let encoded =
CatalogPublishArguments::encode_result(PublishOutcome::Rejected("domain".into())).unwrap();
assert_eq!(
PublishOutcome::decode(encoded).unwrap(),
PublishOutcome::Rejected("domain".into())
);
assert_eq!(
CatalogPingArguments::encode_result(()).unwrap(),
Value::Unit
);
}
#[test]
fn typed_dispatch_validates_decodes_invokes_encodes_and_validates() {
let value = CatalogDefinition::dispatch(&AcceptingCatalog, "publish", publish_arguments(true))
.unwrap()
.unwrap();
let value =
CatalogDefinition::dispatch(&AcceptingCatalog, "publish", publish_arguments(true)).unwrap();
assert_eq!(
PublishOutcome::decode(value).unwrap(),
PublishOutcome::Published(true)
);
assert_eq!(
CatalogDefinition::dispatch(&AcceptingCatalog, "ping", vec![]).unwrap(),
None
CatalogDefinition::dispatch(&AcceptingCatalog, "ping", BTreeMap::new()).unwrap(),
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(_)));
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"));
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"));
}
@@ -245,12 +241,12 @@ fn distinct_operation_names_always_generate_distinct_public_items() {
assert_eq!(descriptor.operations[0].name, "item");
assert_eq!(descriptor.operations[1].name, "item_");
let first = DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item", vec![])
.unwrap()
.unwrap();
let second = DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item_", vec![])
.unwrap()
.unwrap();
let first =
DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item", BTreeMap::new())
.unwrap();
let second =
DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item_", BTreeMap::new())
.unwrap();
assert_eq!(first, Value::String("item".into()));
assert_eq!(second, Value::String("item_".into()));
}
@@ -0,0 +1,8 @@
use wip_interface::interface;
#[interface]
pub trait AsyncOperation {
async fn status(&self) -> wip_interface::OperationResult<String>;
}
fn main() {}
@@ -0,0 +1,5 @@
error: async operations are not supported
--> tests/ui/async_operation.rs:5:5
|
5 | async fn status(&self) -> wip_interface::OperationResult<String>;
| ^^^^^
@@ -0,0 +1,10 @@
use wip_interface::interface;
#[interface]
pub trait DefaultMethod {
fn status(&self) -> wip_interface::OperationResult<String> {
Ok(String::new())
}
}
fn main() {}
@@ -0,0 +1,8 @@
error: WIP operation methods must not have a body
--> tests/ui/default_method.rs:5:64
|
5 | fn status(&self) -> wip_interface::OperationResult<String> {
| ________________________________________________________________^
6 | | Ok(String::new())
7 | | }
| |_____^
@@ -0,0 +1,8 @@
use wip_interface::WipType;
#[derive(WipType)]
struct GenericDto<T> {
value: T,
}
fn main() {}
@@ -0,0 +1,5 @@
error: generic DTOs are not supported by `WipType`
--> tests/ui/generic_dto.rs:4:18
|
4 | struct GenericDto<T> {
| ^^^
+2 -2
View File
@@ -1,9 +1,9 @@
use wip_interface::WipType;
#[derive(WipType)]
enum MixedEnum {
enum UnsupportedEnum {
Empty,
Payload(String),
Payload { value: String },
}
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
--> tests/ui/mixed_enum.rs:4:1
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:6:5
|
4 | enum MixedEnum {
| ^^^^
6 | Payload { value: String },
| ^^^^^^^
@@ -0,0 +1,8 @@
use wip_interface::WipType;
#[derive(WipType)]
struct NestedOption {
values: Vec<Option<String>>,
}
fn main() {}
@@ -0,0 +1,5 @@
error: `Option<T>` must appear directly as the DTO field type
--> tests/ui/nested_option.rs:5:13
|
5 | values: Vec<Option<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>;
| ^^^^^^^^^^^^^^^^ the trait `WipType` is not implemented for `(std::string::String, std::string::String)`
|
= help: the following other types implement trait `WipType`:
JsonValue
Vec<T>
bool
f32
f64
i128
i16
i32
and $N others
help: the trait `WipType` is implemented for `()`
--> src/lib.rs
|
| impl WipType for () {
| ^^^^^^^^^^^^^^^^^^^
error[E0277]: the trait bound `(std::string::String, std::string::String): WipType` is not satisfied
--> 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>;
| ^^^^^^^^^^^^^^^^ the trait `WipType` is not implemented for `(std::string::String, std::string::String)`
|
= help: the following other types implement trait `WipType`:
JsonValue
Vec<T>
bool
f32
f64
i128
i16
i32
and $N others
help: the trait `WipType` is implemented for `()`
--> src/lib.rs
|
| impl WipType for () {
| ^^^^^^^^^^^^^^^^^^^
note: required by a bound in `wip_interface::__private::RecordDecoder::required`
--> src/lib.rs
|
+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 {}
+360
View File
@@ -0,0 +1,360 @@
//! End-to-end use of the public WIP protocol, interface, and host APIs.
//!
//! This workspace package defines application DTOs and interfaces with
//! `wip-interface`, keeps its implementation private, publishes it through a
//! request-scoped `wip-host` provider, and crosses the call boundary only with
//! canonical `wip-protocol` values and path targets.
#![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;
const OBJECT_PATH: &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.
#[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>;
}
/// 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 {
/// 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 provider resolves the current object from a Worldspace path for each
/// call. Interface observations remain separate from object projection, and all
/// 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()),
},
)?;
let rejected = dispatch_publish(
&provider,
catalog.reference(),
PublishRequest {
title: String::new(),
body: None,
},
)?;
let catalog_status = dispatch_string(
&provider,
request(
catalog.reference(),
Some(CATALOG_VALIDATOR),
"status",
BTreeMap::new(),
),
)?;
let audit_status = dispatch_string(
&provider,
request(audit.reference(), None, "status", BTreeMap::new()),
)?;
let host_error = provider
.call_operation(request(
catalog.reference(),
Some(CATALOG_VALIDATOR),
"backend_status",
BTreeMap::new(),
))
.unwrap_err();
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(
provider: &ApplicationProvider,
catalog: &InterfaceRef,
request_value: PublishRequest,
) -> Result<PublishOutcome, Box<dyn Error>> {
let encoded_request = request_value.encode()?;
let result = provider.call_operation(request(
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(
interface: &InterfaceRef,
interface_validator: Option<&[u8]>,
operation: &str,
arguments: BTreeMap<String, wip_protocol::Value>,
) -> CallRequest {
CallRequest {
target: Target {
path: OBJECT_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))
}
}
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())
}
}
}
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_protocol::ProtocolErrorCode;
#[test]
fn unexposed_interfaces_are_membership_failures() {
let mut registry = InterfaceRegistry::new();
let catalog = registry.register(CatalogDefinition, None).unwrap();
let audit = registry.register(AuditDefinition, None).unwrap();
let diagnostics = registry.register(DiagnosticsDefinition, None).unwrap();
let provider = ApplicationProvider { catalog, audit };
let error = provider
.call_operation(request(
diagnostics.reference(),
None,
"diagnostics",
BTreeMap::new(),
))
.unwrap_err();
assert_eq!(error.kind(), CallErrorKind::InterfaceMismatch);
assert_eq!(
error.protocol_error().code,
ProtocolErrorCode::InterfaceMismatch
);
}
}
+13 -2
View File
@@ -1,3 +1,14 @@
fn main() {
println!("Hello, world!");
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let report = wip_public_api_example::run()?;
println!(
"published {} interfaces; catalog={}, audit={}; domain={:?}; host={:?}",
report.published_interfaces.len(),
report.catalog_status,
report.audit_status,
report.rejected,
report.host_failure,
);
Ok(())
}
+24
View File
@@ -0,0 +1,24 @@
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);
}