Compare commits

..
22 changed files with 2197 additions and 43 deletions
Generated
+12 -1
View File
@@ -238,6 +238,15 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
[[package]]
name = "wip-host"
version = "0.1.0"
dependencies = [
"trybuild",
"wip-interface",
"wip-protocol",
]
[[package]]
name = "wip-interface"
version = "0.1.0"
@@ -261,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",
]
+7 -1
View File
@@ -1,6 +1,7 @@
[workspace]
members = [
".",
"crates/wip-host",
"crates/wip-interface",
"crates/wip-interface-macros",
"crates/wip-protocol",
@@ -8,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" }
+140 -14
View File
@@ -2,6 +2,65 @@
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 `OperationCall`/`OperationResult`
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,12 +89,31 @@ 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 enums whose
variants each contain 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;
mixed/named-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 |
| --- | --- |
@@ -45,23 +123,71 @@ dispatch for any `T: Messenger`.
| `f32`, `f64` | finite number |
| `String` | string |
| `wip::Bytes` | bytes |
| `wip::wip_protocol::JsonValue` | JSON |
| `wip::JsonValue` | JSON |
| `wip::Entry<K, V>` | entry |
| `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 |
`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`.
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
choose the ordered interface set for each object publication:
```rust,ignore
let mut interfaces = wip_host::InterfaceRegistry::new();
let messenger = interfaces.register(MessengerDefinition, None)?;
let published = wip_host::PublishedObject::new(request_scoped_object)
.expose(&messenger)?;
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 independent wire 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`.
Application domain failures belong in a derived result union and are returned
through `Ok`. `wip::HostError` is reserved for runtime/backend failures. Invalid
arguments are rejected before invocation; malformed encoded results are
rejected as internal host failures and never returned as application data.
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.
## 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.
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "wip-host"
version = "0.1.0"
edition = "2024"
description = "Host-side object publication and operation dispatch for WIP"
license = "MIT OR Apache-2.0"
[dependencies]
wip-interface = { path = "../wip-interface" }
wip-protocol = { path = "../wip-protocol" }
[dev-dependencies]
trybuild = "1"
+642
View File
@@ -0,0 +1,642 @@
//! Host-side publication and operation dispatch for Web Interface Protocol objects.
//!
//! # 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,
//! 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.
//!
//! 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.
#![deny(missing_docs)]
use std::any::TypeId;
use std::collections::{HashMap, HashSet};
use std::error::Error;
use std::fmt::{self, Display, Formatter};
use std::marker::PhantomData;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use wip_interface::DispatchError;
pub use wip_interface::{InterfaceDefinition, InterfaceImplementation};
pub use wip_protocol;
use wip_protocol::{
InterfaceDescriptor, NamedValue, Object, ObjectId, OperationCall, OperationTarget,
ProtocolError, ProtocolErrorCategory, ValidationError, Value,
};
/// Opaque bytes used as a conditional execution precondition.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Validator(Vec<u8>);
impl Validator {
/// Creates a validator without assigning meaning to its bytes.
#[must_use]
pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
Self(bytes.into())
}
/// Returns the opaque bytes for transport encoding or comparison.
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
/// Opaque host-assigned reference to one registered interface artifact.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct InterfaceRef(String);
impl InterfaceRef {
/// Returns the opaque reference for a protocol transport.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// An immutable observation returned for an opaque interface reference.
#[derive(Debug, Clone, PartialEq)]
pub struct InterfaceObservation {
/// Opaque reference selected by published objects and operation calls.
pub reference: InterfaceRef,
/// 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,
}
#[derive(Debug)]
struct InterfaceArtifact {
reference: InterfaceRef,
validator: Option<Validator>,
descriptor: InterfaceDescriptor,
}
/// A typed registration token for a generated interface definition.
///
/// Tokens can be reused by any number of request-scoped objects. They expose an
/// opaque reference, not the Rust type name or an address.
pub struct RegisteredInterface<D> {
artifact: Arc<InterfaceArtifact>,
definition: PhantomData<fn() -> D>,
}
impl<D> Clone for RegisteredInterface<D> {
fn clone(&self) -> Self {
Self {
artifact: Arc::clone(&self.artifact),
definition: PhantomData,
}
}
}
impl<D> RegisteredInterface<D> {
/// Returns the opaque protocol reference assigned by the registry.
#[must_use]
pub fn reference(&self) -> &InterfaceRef {
&self.artifact.reference
}
}
/// Registry of validated, immutable interface artifacts.
///
/// This registry stores interface definitions, not object instances. Generated
/// definitions are registered explicitly and receive process-local opaque
/// 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.
#[derive(Debug)]
pub struct InterfaceRegistry {
namespace: u64,
next_reference: u64,
definitions: HashSet<TypeId>,
artifacts: HashMap<InterfaceRef, Arc<InterfaceArtifact>>,
}
static NEXT_REGISTRY_NAMESPACE: AtomicU64 = AtomicU64::new(1);
impl Default for InterfaceRegistry {
fn default() -> Self {
Self {
namespace: NEXT_REGISTRY_NAMESPACE.fetch_add(1, Ordering::Relaxed),
next_reference: 0,
definitions: HashSet::new(),
artifacts: HashMap::new(),
}
}
}
impl InterfaceRegistry {
/// Creates an empty interface registry.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 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,
validator: Option<Validator>,
) -> Result<RegisteredInterface<D>, RegistrationError>
where
D: InterfaceDefinition + 'static,
{
let definition = TypeId::of::<D>();
if self.definitions.contains(&definition) {
return Err(RegistrationError::AlreadyRegistered);
}
let mut descriptor = D::descriptor();
descriptor
.validate()
.map_err(RegistrationError::InvalidDescriptor)?;
let reference = InterfaceRef(format!(
"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,
});
self.definitions.insert(definition);
self.artifacts.insert(reference, Arc::clone(&artifact));
Ok(RegisteredInterface {
artifact,
definition: PhantomData,
})
}
/// Resolves a transport-provided opaque reference string in this registry.
///
/// The returned value is registry-scoped and suitable for observation or a
/// qualified [`CallRequest`]. Unknown or foreign references return `None`;
/// constructing arbitrary `InterfaceRef` values is intentionally not part of
/// the public API.
#[must_use]
pub fn resolve(&self, reference: &str) -> Option<InterfaceRef> {
let reference = InterfaceRef(reference.to_owned());
self.artifacts.contains_key(&reference).then_some(reference)
}
/// Observes the descriptor and optional validator for an opaque reference.
#[must_use]
pub fn observe(&self, reference: &InterfaceRef) -> Option<InterfaceObservation> {
self.artifacts
.get(reference)
.map(|artifact| InterfaceObservation {
reference: artifact.reference.clone(),
validator: artifact.validator.clone(),
descriptor: artifact.descriptor.clone(),
})
}
}
/// Failure while registering a generated interface definition.
#[derive(Debug)]
pub enum RegistrationError {
/// The same generated definition was already assigned a reference.
AlreadyRegistered,
/// The generated descriptor is structurally invalid.
InvalidDescriptor(ValidationError),
/// The registry cannot assign another opaque reference.
ReferenceSpaceExhausted,
}
impl Display for RegistrationError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::AlreadyRegistered => {
formatter.write_str("interface definition already registered")
}
Self::InvalidDescriptor(error) => {
write!(formatter, "invalid interface descriptor: {error}")
}
Self::ReferenceSpaceExhausted => {
formatter.write_str("interface reference space exhausted")
}
}
}
}
impl Error for RegistrationError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::InvalidDescriptor(error) => Some(error),
Self::AlreadyRegistered | Self::ReferenceSpaceExhausted => None,
}
}
}
/// 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.
pub trait WipObject {
/// Returns the current logical identity of this object.
fn id(&self) -> ObjectId;
/// 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 {
false
}
}
type DispatchFn<T> = fn(&T, &str, Vec<NamedValue>) -> Result<Option<Value>, DispatchError>;
struct BoundInterface<T> {
artifact: Arc<InterfaceArtifact>,
dispatch: DispatchFn<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.
pub struct PublishedObject<T: WipObject> {
implementation: T,
interfaces: Vec<BoundInterface<T>>,
}
impl<T: WipObject> PublishedObject<T> {
/// Starts a publication with no exposed interfaces.
#[must_use]
pub fn new(implementation: T) -> Self {
Self {
implementation,
interfaces: Vec::new(),
}
}
/// Explicitly exposes a registered generated interface in insertion order.
///
/// The `InterfaceImplementation<T>` bound is generated only when `T`
/// implements the corresponding Rust interface trait. Duplicate opaque
/// references are rejected.
pub fn expose<D>(mut self, interface: &RegisteredInterface<D>) -> Result<Self, PublicationError>
where
D: InterfaceImplementation<T>,
{
if self
.interfaces
.iter()
.any(|bound| bound.artifact.reference == interface.artifact.reference)
{
return Err(PublicationError::DuplicateInterface(
interface.artifact.reference.clone(),
));
}
self.interfaces.push(BoundInterface {
artifact: Arc::clone(&interface.artifact),
dispatch: D::dispatch,
});
Ok(self)
}
/// Returns the host-private implementation without projecting its fields.
#[must_use]
pub fn implementation(&self) -> &T {
&self.implementation
}
/// Builds the protocol object metadata with interface insertion order intact.
#[must_use]
pub fn protocol_object(&self) -> Object {
let object = Object {
id: self.implementation.id(),
interfaces: self
.interfaces
.iter()
.map(|bound| bound.artifact.reference.0.clone())
.collect(),
};
debug_assert!(object.validate().is_ok());
object
}
/// Dispatches one qualified operation against this current publication.
///
/// Preconditions are checked before generated operation code runs. The
/// selected artifact and function pointer are borrowed once and are not
/// resolved again after validator checks.
pub fn dispatch(&self, request: CallRequest) -> Result<CallResult, CallError> {
if request.object != self.implementation.id() {
return Err(CallError::new(
CallErrorKind::ObjectNotFound,
"call target does not identify this published object",
));
}
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 {
return Err(CallError::new(
CallErrorKind::InterfaceMismatch,
"interface is not a member of the current object publication",
));
};
match (
bound.artifact.validator.as_ref(),
request.interface.validator.as_ref(),
) {
(Some(_), None) => {
return Err(CallError::new(
CallErrorKind::InterfaceValidatorRequired,
"registered interface requires a validator",
));
}
(expected, Some(actual)) if expected != Some(actual) => {
return Err(CallError::new(
CallErrorKind::InterfaceValidatorMismatch,
"interface validator does not match the registered descriptor",
));
}
_ => {}
}
if !bound
.artifact
.descriptor
.operations
.iter()
.any(|operation| operation.name == request.operation)
{
return Err(CallError::new(
CallErrorKind::OperationNotFound,
format!("operation `{}` is not declared", request.operation),
));
}
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(),
})
.map_err(|error| CallError::new(CallErrorKind::InvalidArguments, error.to_string()))?;
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())
.map_err(|error| CallError::new(CallErrorKind::InvalidResult, error.to_string()))?;
Ok(CallResult {
value,
validator: self.implementation.validator().cloned(),
})
}
/// Consumes the publication and returns its host-private implementation.
#[must_use]
pub fn into_inner(self) -> T {
self.implementation
}
}
/// Failure to construct an explicit object publication.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PublicationError {
/// The same opaque interface reference was exposed more than once.
DuplicateInterface(InterfaceRef),
}
impl Display for PublicationError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::DuplicateInterface(reference) => write!(
formatter,
"interface `{}` is already exposed by this object",
reference.as_str()
),
}
}
}
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 selected interface is not exposed by the current object.
InterfaceMismatch,
/// The operation is absent from the selected descriptor.
OperationNotFound,
/// Supplied named values do not satisfy the operation declaration.
InvalidArguments,
/// The encoded implementation result violates its declaration.
InvalidResult,
/// This object requires a validator and the call omitted it.
ValidatorRequired,
/// The supplied object validator is stale or belongs to another object.
ValidatorMismatch,
/// The selected mutable interface requires its observed validator.
InterfaceValidatorRequired,
/// The supplied interface validator does not identify the current descriptor.
InterfaceValidatorMismatch,
/// The typed implementation reported a host/backend failure.
Implementation,
}
/// A dispatch failure with a stable kind and non-normative diagnostic.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CallError {
kind: CallErrorKind,
message: String,
}
impl CallError {
fn new(kind: CallErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
fn from_dispatch(error: DispatchError) -> Self {
match error {
DispatchError::UnknownOperation(operation) => Self::new(
CallErrorKind::OperationNotFound,
format!("operation `{operation}` is not declared"),
),
DispatchError::InvalidArguments(error) => {
Self::new(CallErrorKind::InvalidArguments, error.to_string())
}
DispatchError::Decode(error) => {
Self::new(CallErrorKind::InvalidArguments, error.to_string())
}
DispatchError::Host(error) => {
Self::new(CallErrorKind::Implementation, error.to_string())
}
DispatchError::Encode(error) => {
Self::new(CallErrorKind::InvalidResult, error.to_string())
}
DispatchError::InvalidResult(error) => {
Self::new(CallErrorKind::InvalidResult, error.to_string())
}
}
}
/// Returns the stable failure kind.
#[must_use]
pub fn kind(&self) -> CallErrorKind {
self.kind
}
/// Returns the human-readable diagnostic.
#[must_use]
pub fn message(&self) -> &str {
&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.
#[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
}
};
ProtocolError {
category,
message: self.message.clone(),
}
}
}
impl Display for CallError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
write!(
formatter,
"host dispatch failed ({:?}): {}",
self.kind, self.message
)
}
}
impl Error for CallError {}
+5
View File
@@ -0,0 +1,5 @@
#[test]
fn rejects_exposing_an_unimplemented_interface() {
let tests = trybuild::TestCases::new();
tests.compile_fail("tests/ui/unimplemented_interface.rs");
}
+507
View File
@@ -0,0 +1,507 @@
use std::cell::{Cell, RefCell};
use wip_host::{
CallErrorKind, CallRequest, InterfaceRegistry, InterfaceTarget, PublishedObject, Validator,
WipObject,
};
use wip_interface::{
CodecError, HostError, NamedValue, OperationResult, TypeDeclaration, TypeExpr, Value, WipType,
interface,
};
use wip_protocol::{ObjectId, ProtocolErrorCategory};
#[interface]
pub trait Reader {
/// Returns the reader-qualified label.
fn label(&self) -> OperationResult<String>;
/// Adds two integers.
fn add(&self, left: i64, right: i64) -> OperationResult<i64>;
}
#[interface]
pub trait Writer {
/// Returns the writer-qualified label.
fn label(&self) -> OperationResult<String>;
}
#[interface]
pub trait Hidden {
/// Returns a hidden value.
fn hidden(&self) -> OperationResult<String>;
}
#[interface]
pub trait Broken {
/// Returns a deliberately invalid encoded value.
fn broken(&self) -> OperationResult<BadValue>;
}
#[derive(Debug)]
pub struct BadValue;
impl WipType for BadValue {
fn type_expr() -> TypeExpr {
TypeExpr::Integer
}
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> {
Ok(Value::String("not an integer".into()))
}
fn decode(_: Value) -> Result<Self, CodecError> {
Ok(Self)
}
}
struct Document {
id: String,
validator: Option<Validator>,
require_validator: bool,
calls: Cell<usize>,
private_backend_handle: RefCell<String>,
}
impl Document {
fn new(id: &str) -> Self {
Self {
id: id.into(),
validator: None,
require_validator: false,
calls: Cell::new(0),
private_backend_handle: RefCell::new("/private/backend/path".into()),
}
}
fn strict(id: &str, validator: &[u8]) -> Self {
Self {
id: id.into(),
validator: Some(Validator::new(validator)),
require_validator: true,
calls: Cell::new(0),
private_backend_handle: RefCell::new("secret-handle".into()),
}
}
fn record_call(&self) {
self.calls.set(self.calls.get() + 1);
}
}
impl WipObject for Document {
fn id(&self) -> ObjectId {
ObjectId(self.id.clone())
}
fn validator(&self) -> Option<&Validator> {
self.validator.as_ref()
}
fn requires_validator(&self) -> bool {
self.require_validator
}
}
impl Reader for Document {
fn label(&self) -> OperationResult<String> {
self.record_call();
Ok("reader".into())
}
fn add(&self, left: i64, right: i64) -> OperationResult<i64> {
self.record_call();
Ok(left + right)
}
}
impl Writer for Document {
fn label(&self) -> OperationResult<String> {
self.record_call();
Ok("writer".into())
}
}
impl Hidden for Document {
fn hidden(&self) -> OperationResult<String> {
self.record_call();
Ok("hidden".into())
}
}
impl Broken for Document {
fn broken(&self) -> OperationResult<BadValue> {
self.record_call();
Ok(BadValue)
}
}
fn request(
object: &str,
object_validator: Option<&[u8]>,
reference: &wip_host::InterfaceRef,
interface_validator: Option<&[u8]>,
operation: &str,
arguments: Vec<NamedValue>,
) -> CallRequest {
CallRequest {
object: ObjectId(object.into()),
object_validator: object_validator.map(Validator::new),
interface: InterfaceTarget {
reference: reference.clone(),
validator: interface_validator.map(Validator::new),
},
operation: operation.into(),
arguments,
}
}
#[test]
fn registry_exposes_opaque_validated_descriptor_observations() {
let mut registry = InterfaceRegistry::new();
let reader = registry.register(ReaderDefinition, None).unwrap();
assert_ne!(reader.reference().as_str(), "Reader");
assert_eq!(
registry.resolve(reader.reference().as_str()).as_ref(),
Some(reader.reference())
);
assert!(registry.resolve("wip-interface:unknown:0").is_none());
let observation = registry.observe(reader.reference()).unwrap();
assert_eq!(observation.reference, *reader.reference());
assert_eq!(observation.descriptor.name, reader.reference().as_str());
assert_eq!(observation.validator, None);
observation.descriptor.validate().unwrap();
assert!(registry.register(ReaderDefinition, None).is_err());
let mut other_registry = InterfaceRegistry::new();
let other_reader = other_registry.register(ReaderDefinition, None).unwrap();
assert_ne!(reader.reference(), other_reader.reference());
assert!(
registry
.resolve(other_reader.reference().as_str())
.is_none()
);
}
#[test]
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();
let publication = PublishedObject::new(Document::new("document-1"))
.expose(&writer)
.unwrap()
.expose(&reader)
.unwrap();
let object = publication.protocol_object();
assert_eq!(
object.interfaces,
vec![
writer.reference().as_str().to_owned(),
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"));
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);
assert_eq!(
publication
.implementation()
.private_backend_handle
.borrow()
.as_str(),
"/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) {
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
);
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();
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);
}
#[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();
let missing = publication
.dispatch(request(
"strict",
None,
reader.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![],
))
.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",
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(),
Some(b"schema-v1"),
"label",
vec![],
))
.unwrap();
assert_eq!(result.value, Some(Value::String("writer".into())));
}
#[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>;
}
impl Failing for Document {
fn fail(&self) -> OperationResult<String> {
Err(HostError::new("backend unavailable"))
}
}
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
);
}
@@ -0,0 +1,22 @@
use wip_host::{InterfaceRegistry, PublishedObject, WipObject};
use wip_interface::{OperationResult, interface};
use wip_protocol::ObjectId;
#[interface]
pub trait Search {
fn search(&self) -> OperationResult<String>;
}
struct Object;
impl WipObject for Object {
fn id(&self) -> ObjectId {
ObjectId("object".into())
}
}
fn main() {
let mut registry = InterfaceRegistry::new();
let search = registry.register(SearchDefinition, None).unwrap();
let _published = PublishedObject::new(Object).expose(&search).unwrap();
}
@@ -0,0 +1,34 @@
error[E0277]: the trait bound `Object: Search` is not satisfied
--> tests/ui/unimplemented_interface.rs:21:58
|
21 | 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
|
10 | 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]
| ^^^^^^^^^^^^
6 | pub trait Search {
| ^^^^^^
note: required by a bound in `PublishedObject::<T>::expose`
--> src/lib.rs
|
| pub fn expose<D>(mut self, interface: &RegisteredInterface<D>) -> Result<Self, PublicationError>
| ------ required by a bound in this associated function
| where
| D: InterfaceImplementation<T>,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `PublishedObject::<T>::expose`
= note: this error originates in the attribute macro `interface` (in Nightly builds, run with -Z macro-backtrace for more info)
+60 -17
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;
@@ -170,14 +176,8 @@ fn expand_interface(attribute: TokenStream2, item: ItemTrait) -> syn::Result<Tok
}
}
impl #definition_ident {
/// Builds the interface descriptor generated from the trait declaration.
pub fn descriptor() -> ::wip_interface::wip_protocol::InterfaceDescriptor {
<Self as ::wip_interface::InterfaceDefinition>::descriptor()
}
/// Dispatches one validated logical operation call to a typed implementation.
pub fn dispatch<T: #trait_ident>(
impl<T: #trait_ident> ::wip_interface::InterfaceImplementation<T> for #definition_ident {
fn dispatch(
implementation: &T,
operation: &str,
arguments: ::std::vec::Vec<::wip_interface::wip_protocol::NamedValue>,
@@ -197,6 +197,29 @@ fn expand_interface(attribute: TokenStream2, item: ItemTrait) -> syn::Result<Tok
}
}
impl #definition_ident {
/// Builds the interface descriptor generated from the trait declaration.
pub fn descriptor() -> ::wip_interface::wip_protocol::InterfaceDescriptor {
<Self as ::wip_interface::InterfaceDefinition>::descriptor()
}
/// Dispatches one validated logical operation call to a typed implementation.
pub fn dispatch<T: #trait_ident>(
implementation: &T,
operation: &str,
arguments: ::std::vec::Vec<::wip_interface::wip_protocol::NamedValue>,
) -> ::std::result::Result<
::std::option::Option<::wip_interface::wip_protocol::Value>,
::wip_interface::DispatchError,
> {
<Self as ::wip_interface::InterfaceImplementation<T>>::dispatch(
implementation,
operation,
arguments,
)
}
}
#(#argument_items)*
})
}
@@ -453,13 +476,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,
@@ -473,8 +501,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;
@@ -681,7 +714,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(
@@ -967,12 +1005,17 @@ fn derive_union_enum(
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");
return Err(Error::new_spanned(
variant,
"a `WipType` union variant must contain exactly one unnamed field",
));
};
let Some(field) = fields.unnamed.first() else {
return Err(Error::new_spanned(
fields,
"a `WipType` union variant must contain exactly one unnamed field",
));
};
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,
+31 -8
View File
@@ -48,6 +48,21 @@
//! 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.
//!
//! # 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, and all-single-payload enums.
//!
//! 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;
//! mixed or named-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)]
@@ -92,6 +107,21 @@ pub trait InterfaceDefinition {
fn descriptor() -> InterfaceDescriptor;
}
/// Typed dispatch implemented by every generated interface companion.
///
/// `T` must implement the Rust interface trait associated with this definition.
/// Host runtimes use this bound when an interface is explicitly exposed, so an
/// object that does not implement that interface is rejected at compile time.
pub trait InterfaceImplementation<T>: InterfaceDefinition {
/// Validates, decodes, and dispatches one operation to `implementation`,
/// then encodes and validates its result.
fn dispatch(
implementation: &T,
operation: &str,
arguments: Vec<NamedValue>,
) -> Result<Option<Value>, DispatchError>;
}
/// Result convention required for generated interface operations.
///
/// `Ok(T)` is an application result, including domain-failure union cases.
@@ -604,7 +634,6 @@ pub mod __private {
/// Decoder for a protocol record or operation's named argument collection.
pub struct RecordDecoder {
fields: Vec<NamedValue>,
seen: HashSet<String>,
}
impl RecordDecoder {
@@ -618,7 +647,7 @@ pub mod __private {
.at(field.name.clone()));
}
}
Ok(Self { fields, seen })
Ok(Self { fields })
}
/// Decodes a required field.
@@ -651,11 +680,5 @@ pub mod __private {
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
}
}
}
@@ -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> {
| ^^^
@@ -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>>,
| ^^^^^^^^^^^^^^^^^^^
+627
View File
@@ -0,0 +1,627 @@
//! End-to-end use of the public WIP protocol, interface, and host APIs.
//!
//! This workspace package is deliberately separate from the three library
//! crates. It defines application DTOs and interfaces with `wip-interface`,
//! keeps its implementation private, publishes that implementation with
//! `wip-host`, and crosses the call boundary only with `wip-protocol` values.
//!
//! The complete scenario can be run as a normal binary or called directly:
//!
//! ```
//! let report = wip_public_api_example::run()?;
//!
//! assert_eq!(report.published_interfaces.len(), 2);
//! assert_eq!(report.catalog_status, "catalog-ready");
//! assert_eq!(report.audit_status, "audit-ready");
//! assert_eq!(
//! report.host_failure,
//! wip_host::CallErrorKind::Implementation,
//! );
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
#![deny(missing_docs)]
use std::error::Error;
use std::io;
use wip_host::{
CallErrorKind, CallRequest, InterfaceRef, InterfaceRegistry, InterfaceTarget, PublishedObject,
Validator, WipObject,
};
use wip_interface as wip;
use wip_interface::WipType;
use wip_protocol::{
InterfaceObservation, NamedValue, ObjectId, OperationOutcome, OperationResult, OperationTarget,
};
const OBJECT_ID: &str = "article-service";
const OBJECT_VALIDATOR: &[u8] = b"object-v1";
const CATALOG_VALIDATOR: &[u8] = b"catalog-v1";
/// An application-owned request DTO mapped to a protocol record.
#[derive(Debug, Clone, PartialEq, Eq, wip::WipType)]
pub struct PublishRequest {
/// Title of the article to publish.
pub title: String,
/// Optional article body; omission is distinct from JSON null.
pub body: Option<String>,
}
/// The application domain result of a publication attempt.
///
/// Rejection is an ordinary typed result. It is intentionally distinct from a
/// host or backend failure, which uses [`wip::HostError`].
#[derive(Debug, Clone, PartialEq, Eq, wip::WipType)]
pub enum PublishOutcome {
/// Publication succeeded and returned its application identifier.
Published(String),
/// An application policy rejected the publication.
Rejected(String),
}
/// Article publication operations.
#[wip::interface]
pub trait Catalog {
/// Publishes an article or reports an application-domain rejection.
///
/// # Returns
/// The typed application outcome.
fn publish(
&self,
/// Application publication request.
request: PublishRequest,
) -> wip::OperationResult<PublishOutcome>;
/// Returns the catalog-qualified status.
///
/// # Returns
/// A catalog status label.
fn status(&self) -> wip::OperationResult<String>;
/// Demonstrates a host/backend failure rather than a domain result.
///
/// # Returns
/// A backend status only when the host can reach it.
fn backend_status(&self) -> wip::OperationResult<String>;
}
/// Audit operations exposed by the same object.
#[wip::interface]
pub trait Audit {
/// Returns the audit-qualified status.
///
/// This deliberately has the same operation name as [`Catalog::status`].
///
/// # Returns
/// An audit status label.
fn status(&self) -> wip::OperationResult<String>;
}
/// A registered but deliberately unexposed interface used to demonstrate that
/// Rust trait implementation does not imply protocol membership.
#[wip::interface]
pub trait Diagnostics {
/// Returns host-private diagnostics when the interface is explicitly exposed.
///
/// # Returns
/// A diagnostic label.
fn diagnostics(&self) -> wip::OperationResult<String>;
}
/// Observable outcomes from the complete public-API scenario.
#[derive(Debug, Clone, PartialEq)]
pub struct DemoReport {
/// Opaque interface references published by the object, in exposure order.
pub published_interfaces: Vec<String>,
/// Descriptor identity observed for the catalog interface.
pub catalog_descriptor: String,
/// Descriptor identity observed for the audit interface.
pub audit_descriptor: 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 operation result built from the host failure.
pub protocol_failure: OperationResult,
}
/// Runs the complete end-to-end example.
///
/// The function registers and observes generated descriptors, explicitly
/// exposes two interfaces on one host-private object, validates the resulting
/// protocol observation, dispatches interface-qualified calls using named
/// [`wip_protocol::Value`] arguments, decodes validated values back to
/// application types, and keeps a domain rejection distinct from a host
/// failure.
///
/// # Errors
///
/// Returns an error if registration, publication, observation validation,
/// dispatch, or application-value decoding fails unexpectedly.
pub fn run() -> Result<DemoReport, Box<dyn Error>> {
let mut registry = InterfaceRegistry::new();
let catalog = registry.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))?;
let audit = registry.register(AuditDefinition, None)?;
let _diagnostics = registry.register(DiagnosticsDefinition, None)?;
let publication = PublishedObject::new(ApplicationService::new())
.expose(&catalog)?
.expose(&audit)?;
let object = publication.protocol_object();
// Model a transport/provider that retained only the protocol object's
// opaque strings, then resolved them back through the registry's public API.
let catalog_wire_reference = object
.interfaces
.first()
.ok_or_else(|| io::Error::other("catalog interface was not published"))?;
let audit_wire_reference = object
.interfaces
.get(1)
.ok_or_else(|| io::Error::other("audit interface was not published"))?;
let catalog_reference = registry
.resolve(catalog_wire_reference)
.ok_or_else(|| io::Error::other("published catalog reference did not resolve"))?;
let audit_reference = registry
.resolve(audit_wire_reference)
.ok_or_else(|| io::Error::other("published audit reference did not resolve"))?;
let catalog_observation = registry
.observe(&catalog_reference)
.ok_or_else(|| io::Error::other("registered catalog descriptor was not observable"))?;
let audit_observation = registry
.observe(&audit_reference)
.ok_or_else(|| io::Error::other("registered audit descriptor was not observable"))?;
InterfaceObservation {
object: object.clone(),
descriptors: vec![
catalog_observation.descriptor.clone(),
audit_observation.descriptor.clone(),
],
}
.validate()?;
let published = dispatch_publish(
&publication,
&catalog_observation.reference,
PublishRequest {
title: "Public APIs".into(),
body: Some("protocol values in, validated values out".into()),
},
)?;
let rejected = dispatch_publish(
&publication,
&catalog_observation.reference,
PublishRequest {
title: String::new(),
body: None,
},
)?;
let catalog_status = dispatch_string(
&publication,
request(
catalog_observation.reference.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
vec![],
),
"catalog status",
)?;
let audit_status = dispatch_string(
&publication,
request(audit_observation.reference.clone(), None, "status", vec![]),
"audit status",
)?;
let host_error = match publication.dispatch(request(
catalog_observation.reference.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"backend_status",
vec![],
)) {
Ok(_) => {
return Err(io::Error::other(
"the deliberately unavailable example backend unexpectedly succeeded",
)
.into());
}
Err(error) => error,
};
if host_error.kind() != CallErrorKind::Implementation {
return Err(io::Error::other(format!(
"unexpected host failure kind: {:?}",
host_error.kind()
))
.into());
}
let protocol_error = host_error.protocol_error();
let protocol_failure = OperationResult {
target: OperationTarget {
object: ObjectId(OBJECT_ID.into()),
interface: catalog_observation.reference.as_str().into(),
operation: "backend_status".into(),
},
outcome: OperationOutcome::Failed(protocol_error),
};
Ok(DemoReport {
published_interfaces: object.interfaces,
catalog_descriptor: catalog_observation.descriptor.name,
audit_descriptor: audit_observation.descriptor.name,
published,
rejected,
catalog_status,
audit_status,
host_failure: host_error.kind(),
protocol_failure,
})
}
fn dispatch_publish(
publication: &PublishedObject<ApplicationService>,
catalog: &InterfaceRef,
request_value: PublishRequest,
) -> Result<PublishOutcome, Box<dyn Error>> {
let encoded_request = request_value.encode()?;
let result = publication.dispatch(request(
catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"publish",
vec![NamedValue {
name: "request".into(),
value: encoded_request,
}],
))?;
let value = result
.value
.ok_or_else(|| io::Error::other("publish returned no application value"))?;
Ok(PublishOutcome::decode(value)?)
}
fn dispatch_string(
publication: &PublishedObject<ApplicationService>,
request: CallRequest,
context: &str,
) -> Result<String, Box<dyn Error>> {
let result = publication.dispatch(request)?;
let value = result
.value
.ok_or_else(|| io::Error::other(format!("{context} returned no value")))?;
Ok(String::decode(value)?)
}
fn request(
interface: InterfaceRef,
interface_validator: Option<Validator>,
operation: &str,
arguments: Vec<NamedValue>,
) -> CallRequest {
CallRequest {
object: ObjectId(OBJECT_ID.into()),
object_validator: Some(Validator::new(OBJECT_VALIDATOR)),
interface: InterfaceTarget {
reference: interface,
validator: interface_validator,
},
operation: operation.into(),
arguments,
}
}
struct ApplicationService {
object_validator: Validator,
backend_connection: String,
}
impl ApplicationService {
fn new() -> Self {
Self {
object_validator: Validator::new(OBJECT_VALIDATOR),
backend_connection: "private://article-database".into(),
}
}
}
impl WipObject for ApplicationService {
fn id(&self) -> ObjectId {
ObjectId(OBJECT_ID.into())
}
fn validator(&self) -> Option<&Validator> {
Some(&self.object_validator)
}
fn requires_validator(&self) -> bool {
true
}
}
impl Catalog for ApplicationService {
fn publish(&self, request: PublishRequest) -> wip::OperationResult<PublishOutcome> {
if request.title.trim().is_empty() {
Ok(PublishOutcome::Rejected("a title is required".into()))
} else {
Ok(PublishOutcome::Published("article-1".into()))
}
}
fn status(&self) -> wip::OperationResult<String> {
Ok("catalog-ready".into())
}
fn backend_status(&self) -> wip::OperationResult<String> {
if self.backend_connection.starts_with("private://") {
Err(wip::HostError::new("backend unavailable"))
} else {
Ok("backend-ready".into())
}
}
}
impl Audit for ApplicationService {
fn status(&self) -> wip::OperationResult<String> {
Ok("audit-ready".into())
}
}
impl Diagnostics for ApplicationService {
fn diagnostics(&self) -> wip::OperationResult<String> {
Ok("host-private".into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use wip_host::CallError;
use wip_interface::{CodecError, TypeDeclaration, TypeExpr};
use wip_protocol::{ProtocolErrorCategory, Value};
/// A deliberately inconsistent custom codec used to prove result validation.
pub struct InvalidValue;
impl WipType for InvalidValue {
fn type_expr() -> TypeExpr {
TypeExpr::String
}
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> {
Ok(Value::Integer(7))
}
fn decode(_: Value) -> Result<Self, CodecError> {
Ok(Self)
}
}
/// Test-only interface whose custom codec emits an invalid result.
#[wip::interface]
pub trait BrokenResult {
/// Returns a value that violates its declared protocol type.
fn broken_result(&self) -> wip::OperationResult<InvalidValue>;
}
impl BrokenResult for ApplicationService {
fn broken_result(&self) -> wip::OperationResult<InvalidValue> {
Ok(InvalidValue)
}
}
struct Fixture {
publication: PublishedObject<ApplicationService>,
catalog: InterfaceRef,
audit: InterfaceRef,
diagnostics: InterfaceRef,
}
fn fixture() -> Fixture {
let mut registry = InterfaceRegistry::new();
let catalog = registry
.register(CatalogDefinition, Some(Validator::new(CATALOG_VALIDATOR)))
.unwrap();
let audit = registry.register(AuditDefinition, None).unwrap();
let diagnostics = registry.register(DiagnosticsDefinition, None).unwrap();
let publication = PublishedObject::new(ApplicationService::new())
.expose(&catalog)
.unwrap()
.expose(&audit)
.unwrap();
Fixture {
publication,
catalog: catalog.reference().clone(),
audit: audit.reference().clone(),
diagnostics: diagnostics.reference().clone(),
}
}
fn assert_failure(error: CallError, kind: CallErrorKind, category: ProtocolErrorCategory) {
assert_eq!(error.kind(), kind);
assert_eq!(error.protocol_error().category, category);
}
#[test]
fn interface_membership_and_same_name_operations_are_qualified() {
let fixture = fixture();
let catalog = dispatch_string(
&fixture.publication,
request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
vec![],
),
"catalog status",
)
.unwrap();
let audit = dispatch_string(
&fixture.publication,
request(fixture.audit.clone(), None, "status", vec![]),
"audit status",
)
.unwrap();
assert_eq!(catalog, "catalog-ready");
assert_eq!(audit, "audit-ready");
let not_exposed = fixture
.publication
.dispatch(request(fixture.diagnostics, None, "diagnostics", vec![]))
.unwrap_err();
assert_failure(
not_exposed,
CallErrorKind::InterfaceMismatch,
ProtocolErrorCategory::InterfaceNotFound,
);
}
#[test]
fn every_host_precondition_and_dispatch_failure_is_typed() {
let fixture = fixture();
let mut wrong_object = request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
vec![],
);
wrong_object.object = ObjectId("other-object".into());
assert_failure(
fixture.publication.dispatch(wrong_object).unwrap_err(),
CallErrorKind::ObjectNotFound,
ProtocolErrorCategory::ObjectNotFound,
);
let mut missing_object_validator = request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
vec![],
);
missing_object_validator.object_validator = None;
assert_failure(
fixture
.publication
.dispatch(missing_object_validator)
.unwrap_err(),
CallErrorKind::ValidatorRequired,
ProtocolErrorCategory::InvalidRequest,
);
let mut stale_object_validator = request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"status",
vec![],
);
stale_object_validator.object_validator = Some(Validator::new(b"stale-object"));
assert_failure(
fixture
.publication
.dispatch(stale_object_validator)
.unwrap_err(),
CallErrorKind::ValidatorMismatch,
ProtocolErrorCategory::InvalidRequest,
);
assert_failure(
fixture
.publication
.dispatch(request(fixture.catalog.clone(), None, "status", vec![]))
.unwrap_err(),
CallErrorKind::InterfaceValidatorRequired,
ProtocolErrorCategory::InvalidRequest,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
Some(Validator::new(b"stale-interface")),
"status",
vec![],
))
.unwrap_err(),
CallErrorKind::InterfaceValidatorMismatch,
ProtocolErrorCategory::InvalidRequest,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"missing",
vec![],
))
.unwrap_err(),
CallErrorKind::OperationNotFound,
ProtocolErrorCategory::OperationNotFound,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog.clone(),
Some(Validator::new(CATALOG_VALIDATOR)),
"publish",
vec![NamedValue {
name: "request".into(),
value: Value::String("not a record".into()),
}],
))
.unwrap_err(),
CallErrorKind::InvalidArguments,
ProtocolErrorCategory::InvalidArguments,
);
assert_failure(
fixture
.publication
.dispatch(request(
fixture.catalog,
Some(Validator::new(CATALOG_VALIDATOR)),
"backend_status",
vec![],
))
.unwrap_err(),
CallErrorKind::Implementation,
ProtocolErrorCategory::Internal,
);
}
#[test]
fn invalid_implementation_result_never_crosses_the_host_boundary() {
let mut registry = InterfaceRegistry::new();
let broken = registry.register(BrokenResultDefinition, None).unwrap();
let publication = PublishedObject::new(ApplicationService::new())
.expose(&broken)
.unwrap();
let error = publication
.dispatch(request(
broken.reference().clone(),
None,
"broken_result",
vec![],
))
.unwrap_err();
assert_failure(
error,
CallErrorKind::InvalidResult,
ProtocolErrorCategory::Internal,
);
}
}
+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(())
}
+27
View File
@@ -0,0 +1,27 @@
use wip_host::CallErrorKind;
use wip_protocol::{OperationOutcome, ProtocolErrorCategory};
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, report.published_interfaces[0]);
assert_eq!(report.audit_descriptor, report.published_interfaces[1]);
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!(matches!(
report.protocol_failure.outcome,
OperationOutcome::Failed(error) if error.category == ProtocolErrorCategory::Internal
));
}