Compare commits

..
Author SHA1 Message Date
Hare f9f083d4c6 fix(host): align descriptor and publication identity 2026-09-28 23:44:43 +09:00
Hare 32f3fb6fb1 feat(host): add object publication and dispatch 2026-09-28 23:36:50 +09:00
Hare 3f94d48f04 fix(interface): preserve operation item uniqueness
Disambiguate generated argument types when distinct operation names normalize to the same PascalCase form, and add the requested Number round-trip and collision regression coverage.
2026-09-28 23:21:13 +09:00
Hare 18ac8844b0 feat(interface): add authoring macros and codecs
Add the wip-interface facade and proc-macro implementation for typed interface descriptors, DTO value codecs, validated dispatch, metadata extraction, and span-aware compile failures.
2026-09-28 23:11:17 +09:00
38 changed files with 4345 additions and 1 deletions
Generated
+269
View File
@@ -2,6 +2,269 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "target-tuple"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "876fef147edbcbddc8ac5cbbba92c7b86519e314e86638596c09673b2ed01e7f"
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "toml"
version = "1.1.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "trybuild"
version = "1.0.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0cabaa10be1917331a313866bd94526343e03c77bcf69144b62b072ad35d47c"
dependencies = [
"glob",
"serde",
"serde_derive",
"serde_json",
"target-tuple",
"termcolor",
"toml",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
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"
dependencies = [
"trybuild",
"wip-interface-macros",
"wip-protocol",
]
[[package]]
name = "wip-interface-macros"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "wip-protocol"
version = "0.1.0"
@@ -12,3 +275,9 @@ version = "0.1.0"
dependencies = [
"wip-protocol",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+7 -1
View File
@@ -1,5 +1,11 @@
[workspace]
members = [".", "crates/wip-protocol"]
members = [
".",
"crates/wip-host",
"crates/wip-interface",
"crates/wip-interface-macros",
"crates/wip-protocol",
]
resolver = "3"
[package]
+99
View File
@@ -0,0 +1,99 @@
# wip-rs
Rust building blocks for the Web Interface Protocol (WIP).
## Interface authoring
Applications depend on `wip-interface`, normally imported as `wip`:
```rust
use wip_interface as wip;
#[derive(wip::WipType)]
struct Message {
/// Message body.
text: String,
}
#[wip::interface]
pub trait Messenger {
/// Sends a message.
///
/// # Returns
/// Whether the application accepted the message.
fn send(
&self,
/// Message to send.
message: Message,
) -> wip::OperationResult<bool>;
}
```
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`.
### Type mapping
| Rust | Protocol |
| --- | --- |
| `()` as an operation result | no returned value |
| `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 |
| `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.
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 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`.
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.
+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"
+630
View File
@@ -0,0 +1,630 @@
//! 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,
})
}
/// 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");
}
+497
View File
@@ -0,0 +1,497 @@
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");
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());
}
#[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)
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "wip-interface-macros"
version = "0.1.0"
edition = "2024"
description = "Procedural macros for WIP interface declarations and DTO codecs"
license = "MIT OR Apache-2.0"
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full", "visit"] }
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "wip-interface"
version = "0.1.0"
edition = "2024"
description = "Public Rust authoring API for WIP interfaces and operation value codecs"
license = "MIT OR Apache-2.0"
[dependencies]
wip-interface-macros = { path = "../wip-interface-macros" }
wip-protocol = { path = "../wip-protocol" }
[dev-dependencies]
trybuild = "1"
+676
View File
@@ -0,0 +1,676 @@
//! Public authoring API for Web Interface Protocol (WIP) interfaces.
//!
//! Add `wip-interface` to an application and import it under the short name
//! used by the attribute examples:
//!
//! ```
//! use wip_interface as wip;
//!
//! #[derive(Debug, PartialEq, wip::WipType)]
//! struct Greeting {
//! /// Text shown to the recipient.
//! message: String,
//! }
//!
//! #[wip::interface]
//! pub trait Greeter {
//! /// Builds a greeting.
//! ///
//! /// # Returns
//! /// The generated greeting.
//! fn greet(&self, /// Person to greet.
//! name: String) -> wip::OperationResult<Greeting>;
//! }
//! ```
//!
//! The attribute and derive are re-exported by this facade; applications do
//! not depend on the implementation-only `wip-interface-macros` crate.
//!
//! # Rust to protocol type mapping
//!
//! | Rust type | Protocol type/value |
//! | --- | --- |
//! | `()` as an operation result | no return declaration / no value |
//! | `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>` |
//! | `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 |
//!
//! `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.
#![deny(missing_docs)]
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,
};
/// Converts one Rust application type to and from its protocol representation.
///
/// Implementations must describe the same shape accepted by [`WipType::decode`]
/// and produced by [`WipType::encode`]. Prefer deriving this trait for named
/// DTOs. Its declaration-collection method exists for generated interface code
/// and normally should not be called by applications.
pub trait WipType: Sized {
/// Returns the protocol type expression for this Rust type.
fn type_expr() -> TypeExpr;
/// Adds named declarations reachable from this type to `declarations`.
///
/// Implementations must be cycle-safe and preserve declaration order.
fn collect_declarations(declarations: &mut Vec<TypeDeclaration>);
/// Encodes this value into the transport-independent protocol value model.
fn encode(self) -> Result<Value, CodecError>;
/// Decodes a previously validated protocol value.
fn decode(value: Value) -> Result<Self, CodecError>;
}
/// Static metadata implemented by every companion generated by
/// [`interface`].
pub trait InterfaceDefinition {
/// Builds the interface descriptor solely from its trait declaration.
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.
/// `Err(HostError)` means the host or runtime could not perform the operation.
pub type OperationResult<T> = Result<T, HostError>;
/// A host/runtime failure, kept separate from the operation's application
/// return type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostError {
message: String,
}
impl HostError {
/// Creates a host failure with a human-readable diagnostic.
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
/// Returns the diagnostic message.
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
impl Display for HostError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl Error for HostError {}
/// A failure while converting between a Rust value and a protocol [`Value`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodecError {
path: Vec<String>,
message: String,
}
impl CodecError {
/// Creates a codec failure at the current value.
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self {
path: Vec::new(),
message: message.into(),
}
}
/// Creates a structural-kind mismatch diagnostic.
#[must_use]
pub fn expected(expected: &str, actual: &Value) -> Self {
Self::new(format!("expected {expected}, found {:?}", actual.kind()))
}
/// Prepends a field, variant, or collection position to the error path.
#[must_use]
pub fn at(mut self, segment: impl Into<String>) -> Self {
self.path.insert(0, segment.into());
self
}
/// Returns the path from the decoded root to the failing value.
#[must_use]
pub fn path(&self) -> &[String] {
&self.path
}
/// Returns the human-readable reason for the failure.
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
impl Display for CodecError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
if self.path.is_empty() {
write!(formatter, "WIP codec error: {}", self.message)
} else {
write!(
formatter,
"WIP codec error at {}: {}",
self.path.join("."),
self.message
)
}
}
}
impl Error for CodecError {}
/// A failure produced by macro-generated typed operation dispatch.
#[derive(Debug)]
pub enum DispatchError {
/// The requested operation is not declared by this interface.
UnknownOperation(String),
/// Protocol validation rejected the named arguments.
InvalidArguments(wip_protocol::ValidationError),
/// A validated value could not be decoded to its Rust type.
Decode(CodecError),
/// The implementation reported a host/runtime failure.
Host(HostError),
/// The Rust result could not be encoded.
Encode(CodecError),
/// The encoded result did not match the generated declaration.
InvalidResult(wip_protocol::ValidationError),
}
impl Display for DispatchError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownOperation(operation) => {
write!(formatter, "unknown operation `{operation}`")
}
Self::InvalidArguments(error) => write!(formatter, "invalid arguments: {error}"),
Self::Decode(error) => write!(formatter, "argument decoding failed: {error}"),
Self::Host(error) => write!(formatter, "host operation failed: {error}"),
Self::Encode(error) => write!(formatter, "result encoding failed: {error}"),
Self::InvalidResult(error) => write!(formatter, "invalid operation result: {error}"),
}
}
}
impl Error for DispatchError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::UnknownOperation(_) => None,
Self::InvalidArguments(error) | Self::InvalidResult(error) => Some(error),
Self::Decode(error) | Self::Encode(error) => Some(error),
Self::Host(error) => Some(error),
}
}
}
/// An explicitly byte-oriented sequence.
///
/// Use this wrapper when the protocol type must be `Bytes`; `Vec<T>` always
/// denotes a protocol list, including `Vec<u8>`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Bytes(pub Vec<u8>);
impl From<Vec<u8>> for Bytes {
fn from(value: Vec<u8>) -> Self {
Self(value)
}
}
impl From<Bytes> for Vec<u8> {
fn from(value: Bytes) -> Self {
value.0
}
}
/// 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,
}
impl WipType for bool {
fn type_expr() -> TypeExpr {
TypeExpr::Boolean
}
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> {
Ok(Value::Boolean(self))
}
fn decode(value: Value) -> Result<Self, CodecError> {
match value {
Value::Boolean(value) => Ok(value),
other => Err(CodecError::expected("boolean", &other)),
}
}
}
macro_rules! integer_codec {
($($ty:ty),+ $(,)?) => {
$(
impl WipType for $ty {
fn type_expr() -> TypeExpr {
TypeExpr::Integer
}
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> {
let value = i64::try_from(self).map_err(|_| {
CodecError::new("integer is outside the protocol i64 range")
})?;
ensure_safe_integer(value)?;
Ok(Value::Integer(value))
}
fn decode(value: Value) -> Result<Self, CodecError> {
let Value::Integer(value) = value else {
return Err(CodecError::expected("integer", &value));
};
ensure_safe_integer(value)?;
Self::try_from(value).map_err(|_| {
CodecError::new(concat!("integer does not fit Rust `", stringify!($ty), "`"))
})
}
}
)+
};
}
integer_codec!(
i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize
);
fn ensure_safe_integer(value: i64) -> Result<(), CodecError> {
if (MIN_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&value) {
Ok(())
} else {
Err(CodecError::new(format!(
"integer {value} is outside the JavaScript-lossless protocol range"
)))
}
}
impl WipType for f64 {
fn type_expr() -> TypeExpr {
TypeExpr::Number
}
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> {
ensure_finite(self)?;
Ok(Value::Number(self))
}
fn decode(value: Value) -> Result<Self, CodecError> {
match value {
Value::Number(value) => {
ensure_finite(value)?;
Ok(value)
}
other => Err(CodecError::expected("number", &other)),
}
}
}
impl WipType for f32 {
fn type_expr() -> TypeExpr {
TypeExpr::Number
}
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> {
let value = f64::from(self);
ensure_finite(value)?;
Ok(Value::Number(value))
}
fn decode(value: Value) -> Result<Self, CodecError> {
let value = f64::decode(value)?;
let narrowed = value as Self;
if narrowed.is_finite() {
Ok(narrowed)
} else {
Err(CodecError::new("number does not fit finite Rust `f32`"))
}
}
}
fn ensure_finite(value: f64) -> Result<(), CodecError> {
if value.is_finite() {
Ok(())
} else {
Err(CodecError::new("protocol numbers must be finite"))
}
}
impl WipType for String {
fn type_expr() -> TypeExpr {
TypeExpr::String
}
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> {
Ok(Value::String(self))
}
fn decode(value: Value) -> Result<Self, CodecError> {
match value {
Value::String(value) => Ok(value),
other => Err(CodecError::expected("string", &other)),
}
}
}
impl WipType for Bytes {
fn type_expr() -> TypeExpr {
TypeExpr::Bytes
}
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> {
Ok(Value::Bytes(self.0))
}
fn decode(value: Value) -> Result<Self, CodecError> {
match value {
Value::Bytes(value) => Ok(Self(value)),
other => Err(CodecError::expected("bytes", &other)),
}
}
}
impl WipType for JsonValue {
fn type_expr() -> TypeExpr {
TypeExpr::Json
}
fn collect_declarations(_: &mut Vec<TypeDeclaration>) {}
fn encode(self) -> Result<Value, CodecError> {
validate_json(&self)?;
Ok(Value::Json(self))
}
fn decode(value: Value) -> Result<Self, CodecError> {
match value {
Value::Json(value) => {
validate_json(&value)?;
Ok(value)
}
other => Err(CodecError::expected("json", &other)),
}
}
}
fn validate_json(value: &JsonValue) -> Result<(), 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(())
}
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(())
}
JsonValue::Null | JsonValue::Boolean(_) | JsonValue::String(_) => Ok(()),
}
}
impl<T: WipType> WipType for Vec<T> {
fn type_expr() -> TypeExpr {
TypeExpr::List(Box::new(T::type_expr()))
}
fn collect_declarations(declarations: &mut Vec<TypeDeclaration>) {
T::collect_declarations(declarations);
}
fn encode(self) -> Result<Value, CodecError> {
self.into_iter()
.enumerate()
.map(|(index, value)| value.encode().map_err(|error| error.at(index.to_string())))
.collect::<Result<Vec<_>, _>>()
.map(Value::List)
}
fn decode(value: Value) -> Result<Self, CodecError> {
let Value::List(values) = value else {
return Err(CodecError::expected("list", &value));
};
values
.into_iter()
.enumerate()
.map(|(index, value)| T::decode(value).map_err(|error| error.at(index.to_string())))
.collect()
}
}
impl<K: WipType, V: WipType> WipType for Entry<K, V> {
fn type_expr() -> TypeExpr {
TypeExpr::Entry {
key: Box::new(K::type_expr()),
value: Box::new(V::type_expr()),
}
}
fn collect_declarations(declarations: &mut Vec<TypeDeclaration>) {
K::collect_declarations(declarations);
V::collect_declarations(declarations);
}
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"))?),
})
}
fn decode(value: Value) -> Result<Self, CodecError> {
let Value::Entry { key, value } = value else {
return Err(CodecError::expected("entry", &value));
};
Ok(Self {
key: K::decode(*key).map_err(|error| error.at("key"))?,
value: V::decode(*value).map_err(|error| error.at("value"))?,
})
}
}
/// Implementation details used by code emitted from the facade's macros.
///
/// These APIs are public only because macro expansions execute in the
/// application crate. They are excluded from normal generated documentation
/// and are not intended for direct use.
#[doc(hidden)]
pub mod __private {
use std::collections::HashSet;
use wip_protocol::{
InterfaceDescriptor, NamedValue, ObjectId, OperationCall, OperationTarget, TypeDeclaration,
ValidationError, Value,
};
use crate::{CodecError, Documentation, WipType};
/// Builds optional descriptor documentation from generated string literals.
pub fn documentation(summary: &str, details: Option<&str>) -> Option<Documentation> {
if summary.is_empty() && details.is_none() {
None
} else {
Some(Documentation {
summary: summary.into(),
details: details.map(str::to_owned),
})
}
}
/// Registers one declaration and reports whether its dependencies should
/// be traversed. Identical declarations are deduplicated; a conflicting
/// declaration with the same name is retained for protocol validation.
pub fn register_declaration(
declarations: &mut Vec<TypeDeclaration>,
declaration: TypeDeclaration,
) -> bool {
if declarations.iter().any(|existing| existing == &declaration) {
false
} else {
declarations.push(declaration);
true
}
}
/// Validates named arguments against one generated operation declaration.
pub fn validate_arguments(
descriptor: &InterfaceDescriptor,
operation: &str,
arguments: &[NamedValue],
) -> Result<(), ValidationError> {
descriptor.validate_call(&OperationCall {
target: OperationTarget {
object: ObjectId("<typed-dispatch>".into()),
interface: descriptor.name.clone(),
operation: operation.into(),
},
arguments: arguments.to_vec(),
})
}
/// Builds a codec error for a generated structural mismatch.
pub fn decode_type_mismatch(expected: &str, actual: Value) -> CodecError {
CodecError::expected(expected, &actual)
}
/// Builds a codec error for an unknown generated enum or union variant.
pub fn decode_unknown_variant(type_name: &str, variant: String) -> CodecError {
CodecError::new(format!(
"unknown variant `{variant}` for WIP type `{type_name}`"
))
.at(variant)
}
/// Decoder for a protocol record or operation's named argument collection.
pub struct RecordDecoder {
fields: Vec<NamedValue>,
seen: HashSet<String>,
}
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 })
}
/// Decodes a required field.
pub fn required<T: WipType>(&mut self, name: &str) -> Result<T, CodecError> {
let value = self.take(name).ok_or_else(|| {
CodecError::new(format!("missing required field `{name}`")).at(name)
})?;
T::decode(value).map_err(|error| error.at(name))
}
/// Decodes an optional field, where absence maps to `None`.
pub fn optional<T: WipType>(&mut self, name: &str) -> Result<Option<T>, CodecError> {
self.take(name)
.map(T::decode)
.transpose()
.map_err(|error| error.at(name))
}
/// 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()))
} 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
}
}
}
+168
View File
@@ -0,0 +1,168 @@
use wip_interface::wip_protocol::{
EnumDeclaration, JsonMember, JsonValue, NamedValue, RecordDeclaration, TypeDeclaration,
TypeExpr, UnionDeclaration, Value,
};
use wip_interface::{Bytes, Entry, WipType};
/// A request record.
#[derive(Debug, Clone, PartialEq, WipType)]
struct Request {
/// Required title.
title: String,
/// Optional priority.
priority: Option<i32>,
/// Related entries.
related: Vec<Entry<String, bool>>,
/// Opaque bytes.
body: Bytes,
/// Arbitrary metadata.
metadata: JsonValue,
}
/// Available colors.
#[derive(Debug, Clone, PartialEq, WipType)]
enum Color {
/// Warm red.
Red,
/// Cool blue.
Blue,
}
/// A receipt record.
#[derive(Debug, Clone, PartialEq, WipType)]
struct Receipt {
/// Receipt number.
number: u32,
}
/// Application-level publication outcome.
#[derive(Debug, Clone, PartialEq, WipType)]
enum PublishOutcome {
/// Application success.
Published(Receipt),
/// Domain rejection, not a host failure.
Rejected(String),
}
#[test]
fn supported_values_round_trip() {
let request = Request {
title: "hello".into(),
priority: None,
related: vec![Entry {
key: "/articles/1".into(),
value: true,
}],
body: Bytes(vec![1, 2, 3]),
metadata: JsonValue::Object(vec![JsonMember {
name: "source".into(),
value: JsonValue::String("test".into()),
}]),
};
let encoded = request.clone().encode().unwrap();
let Value::Record(fields) = &encoded else {
panic!("record derive must encode a protocol record")
};
assert!(fields.iter().all(|field| field.name != "priority"));
assert_eq!(Request::decode(encoded).unwrap(), request);
let number = 42.5_f64;
assert_eq!(f64::type_expr(), TypeExpr::Number);
assert_eq!(f64::decode(number.encode().unwrap()).unwrap(), number);
let narrow_number = 1.5_f32;
assert_eq!(f32::type_expr(), TypeExpr::Number);
assert_eq!(
f32::decode(narrow_number.encode().unwrap()).unwrap(),
narrow_number
);
for color in [Color::Red, Color::Blue] {
let encoded = color.clone().encode().unwrap();
let decoded = Color::decode(encoded).unwrap();
assert_eq!(decoded, color);
}
for outcome in [
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);
}
}
#[test]
fn derived_declarations_include_docs_optionality_and_dependencies() {
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 {
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_eq!(
fields[2].ty,
TypeExpr::List(Box::new(TypeExpr::Entry {
key: Box::new(TypeExpr::String),
value: Box::new(TypeExpr::Boolean),
}))
);
let color = declarations
.iter()
.find(|declaration| declaration.name() == "Color")
.unwrap();
let TypeDeclaration::Enum(EnumDeclaration { variants, .. }) = color else {
panic!("Color must be an enum declaration")
};
assert_eq!(
variants[0].documentation.as_ref().unwrap().summary,
"Warm red."
);
let outcome = declarations
.iter()
.find(|declaration| declaration.name() == "PublishOutcome")
.unwrap();
let TypeDeclaration::Union(UnionDeclaration { variants, .. }) = outcome else {
panic!("PublishOutcome must be a union declaration")
};
assert_eq!(variants[0].ty, TypeExpr::Named("Receipt".into()));
assert!(
declarations
.iter()
.any(|declaration| declaration.name() == "Receipt")
);
}
#[test]
fn codecs_reject_invalid_boundaries() {
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(vec![NamedValue {
name: "unknown".into(),
value: Value::String("value".into()),
}]))
.is_err()
);
assert!(Color::decode(Value::Enum("Green".into())).is_err());
}
@@ -0,0 +1,5 @@
#[test]
fn rejects_unsupported_interface_and_dto_syntax() {
let tests = trybuild::TestCases::new();
tests.compile_fail("tests/ui/*.rs");
}
+256
View File
@@ -0,0 +1,256 @@
use wip_interface::wip_protocol::{NamedValue, TypeDeclaration, TypeExpr, Value};
use wip_interface::{DispatchError, HostError, OperationResult, WipType, interface};
/// Publication request.
#[derive(Debug, Clone, PartialEq, WipType)]
pub struct PublishRequest {
/// Item title.
pub title: String,
}
/// Application outcome, including domain failure.
#[derive(Debug, Clone, PartialEq, WipType)]
pub enum PublishOutcome {
/// The item was accepted.
Published(bool),
/// The application rejected the item.
Rejected(String),
}
/// Catalog editing capability.
///
/// The shape is implementation-independent.
#[interface]
pub trait Catalog {
/// Publishes one item.
///
/// This operation can report a domain rejection in its application union.
///
/// # Returns
/// The application publication outcome.
fn publish(
&self,
/// Item to publish.
request: PublishRequest,
/// Optional audit note.
note: Option<String>,
) -> OperationResult<PublishOutcome>;
/// Checks connectivity without returning an application value.
fn ping(&self) -> OperationResult<()>;
/// Demonstrates a host/runtime failure.
///
/// # Returns
/// A value only when the host succeeds.
fn host_value(&self) -> OperationResult<String>;
}
struct AcceptingCatalog;
impl Catalog for AcceptingCatalog {
fn publish(
&self,
request: PublishRequest,
note: Option<String>,
) -> OperationResult<PublishOutcome> {
assert_eq!(request.title, "example");
assert_eq!(note.as_deref(), Some("audit"));
Ok(PublishOutcome::Published(true))
}
fn ping(&self) -> OperationResult<()> {
Ok(())
}
fn host_value(&self) -> OperationResult<String> {
Err(HostError::new("backend unavailable"))
}
}
struct RejectingCatalog;
impl Catalog for RejectingCatalog {
fn publish(
&self,
_request: PublishRequest,
_note: Option<String>,
) -> OperationResult<PublishOutcome> {
Ok(PublishOutcome::Rejected("policy".into()))
}
fn ping(&self) -> OperationResult<()> {
Ok(())
}
fn host_value(&self) -> OperationResult<String> {
Ok("ready".into())
}
}
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",
PublishRequest {
title: "example".into(),
}
.encode()
.unwrap(),
)];
if with_note {
arguments.push(named("note", Value::String("audit".into())));
}
arguments
}
#[test]
fn descriptor_is_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.documentation.as_ref().unwrap().summary,
"Catalog editing capability."
);
assert_eq!(
first.documentation.as_ref().unwrap().details.as_deref(),
Some("The shape is implementation-independent.")
);
assert_eq!(first.operations.len(), 3);
let publish = &first.operations[0];
assert_eq!(publish.name, "publish");
assert_eq!(publish.parameters[0].name, "request");
assert_eq!(
publish.parameters[0]
.documentation
.as_ref()
.unwrap()
.summary,
"Item to publish."
);
assert!(!publish.parameters[0].optional);
assert!(publish.parameters[1].optional);
assert_eq!(
publish
.returns
.as_ref()
.unwrap()
.documentation
.as_ref()
.unwrap()
.summary,
"The application publication outcome."
);
assert_eq!(
publish.returns.as_ref().unwrap().ty,
TypeExpr::Named("PublishOutcome".into())
);
assert!(first.operations[1].returns.is_none());
assert!(
first
.declarations
.iter()
.any(|declaration| matches!(declaration, TypeDeclaration::Record(_)))
);
assert!(
first
.declarations
.iter()
.any(|declaration| matches!(declaration, TypeDeclaration::Union(_)))
);
// Both implementation types satisfy the same generated dispatch boundary;
// neither can affect the descriptor shape.
fn accepts_definition<T: Catalog>(_: &T) {}
accepts_definition(&AcceptingCatalog);
accepts_definition(&RejectingCatalog);
}
#[test]
fn generated_argument_decoder_and_result_encoder_round_trip() {
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();
assert_eq!(
PublishOutcome::decode(encoded).unwrap(),
PublishOutcome::Rejected("domain".into())
);
}
#[test]
fn typed_dispatch_validates_decodes_invokes_encodes_and_validates() {
let value = CatalogDefinition::dispatch(&AcceptingCatalog, "publish", publish_arguments(true))
.unwrap()
.unwrap();
assert_eq!(
PublishOutcome::decode(value).unwrap(),
PublishOutcome::Published(true)
);
assert_eq!(
CatalogDefinition::dispatch(&AcceptingCatalog, "ping", vec![]).unwrap(),
None
);
let invalid = CatalogDefinition::dispatch(&AcceptingCatalog, "publish", vec![]).unwrap_err();
assert!(matches!(invalid, DispatchError::InvalidArguments(_)));
let host = CatalogDefinition::dispatch(&AcceptingCatalog, "host_value", vec![]).unwrap_err();
assert!(matches!(host, DispatchError::Host(error) if error.message() == "backend unavailable"));
let unknown = CatalogDefinition::dispatch(&AcceptingCatalog, "missing", vec![]).unwrap_err();
assert!(matches!(unknown, DispatchError::UnknownOperation(name) if name == "missing"));
}
/// Operations whose distinct source names normalize to the same PascalCase.
#[interface]
pub trait DistinctOperations {
/// Returns the first item marker.
fn item(&self) -> OperationResult<String>;
/// Returns the underscored item marker.
fn item_(&self) -> OperationResult<String>;
}
struct DistinctImplementation;
impl DistinctOperations for DistinctImplementation {
fn item(&self) -> OperationResult<String> {
Ok("item".into())
}
fn item_(&self) -> OperationResult<String> {
Ok("item_".into())
}
}
#[test]
fn distinct_operation_names_always_generate_distinct_public_items() {
let descriptor = DistinctOperationsDefinition::descriptor();
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();
assert_eq!(first, Value::String("item".into()));
assert_eq!(second, Value::String("item_".into()));
}
@@ -0,0 +1,10 @@
use wip_interface::{OperationResult, interface};
#[interface]
pub trait AssociatedType {
type Item;
fn load(&self) -> OperationResult<String>;
}
fn main() {}
@@ -0,0 +1,13 @@
error: associated types are not supported in WIP interfaces
--> tests/ui/associated_type.rs:5:5
|
5 | type Item;
| ^^^^^^^^^^
warning: unused import: `OperationResult`
--> tests/ui/associated_type.rs:1:21
|
1 | use wip_interface::{OperationResult, interface};
| ^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
@@ -0,0 +1,8 @@
use wip_interface::{OperationResult, interface};
#[interface]
pub trait BadReceiver {
fn load(&mut self) -> OperationResult<String>;
}
fn main() {}
@@ -0,0 +1,13 @@
error: the only supported receiver is exactly `&self`
--> tests/ui/bad_receiver.rs:5:13
|
5 | fn load(&mut self) -> OperationResult<String>;
| ^^^^^^^^^
warning: unused import: `OperationResult`
--> tests/ui/bad_receiver.rs:1:21
|
1 | use wip_interface::{OperationResult, interface};
| ^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
@@ -0,0 +1,8 @@
use wip_interface::{OperationResult, interface};
#[interface]
pub trait BadReturn {
fn load(&self) -> String;
}
fn main() {}
@@ -0,0 +1,13 @@
error: an operation must explicitly return `OperationResult<T>`
--> tests/ui/bad_return.rs:5:23
|
5 | fn load(&self) -> String;
| ^^^^^^
warning: unused import: `OperationResult`
--> tests/ui/bad_return.rs:1:21
|
1 | use wip_interface::{OperationResult, interface};
| ^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
@@ -0,0 +1,8 @@
use wip_interface::{OperationResult, interface};
#[interface]
pub trait BorrowedValue<'a> {
fn load(&self, value: &'a str) -> OperationResult<String>;
}
fn main() {}
@@ -0,0 +1,13 @@
error: generic WIP interface traits are not supported
--> tests/ui/custom_lifetime.rs:4:24
|
4 | pub trait BorrowedValue<'a> {
| ^^^^
warning: unused import: `OperationResult`
--> tests/ui/custom_lifetime.rs:1:21
|
1 | use wip_interface::{OperationResult, interface};
| ^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
@@ -0,0 +1,9 @@
use wip_interface::{OperationResult, interface};
#[interface]
pub trait DuplicateOperation {
fn load(&self) -> OperationResult<String>;
fn load(&self, other: String) -> OperationResult<String>;
}
fn main() {}
@@ -0,0 +1,13 @@
error: duplicate operation name `load`
--> tests/ui/duplicate_operation.rs:6:8
|
6 | fn load(&self, other: String) -> OperationResult<String>;
| ^^^^
warning: unused import: `OperationResult`
--> tests/ui/duplicate_operation.rs:1:21
|
1 | use wip_interface::{OperationResult, interface};
| ^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
@@ -0,0 +1,8 @@
use wip_interface::{OperationResult, interface};
#[interface]
pub trait GenericOperation {
fn load<T>(&self, value: T) -> OperationResult<String>;
}
fn main() {}
@@ -0,0 +1,13 @@
error: generic operations are not supported
--> tests/ui/generic_method.rs:5:12
|
5 | fn load<T>(&self, value: T) -> OperationResult<String>;
| ^^^
warning: unused import: `OperationResult`
--> tests/ui/generic_method.rs:1:21
|
1 | use wip_interface::{OperationResult, interface};
| ^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
@@ -0,0 +1,8 @@
use wip_interface::{OperationResult, interface};
#[interface]
pub trait Inherited: Send {
fn load(&self) -> OperationResult<String>;
}
fn main() {}
@@ -0,0 +1,13 @@
error: WIP interface trait inheritance is not supported
--> tests/ui/inherited_trait.rs:4:22
|
4 | pub trait Inherited: Send {
| ^^^^
warning: unused import: `OperationResult`
--> tests/ui/inherited_trait.rs:1:21
|
1 | use wip_interface::{OperationResult, interface};
| ^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
@@ -0,0 +1,9 @@
use wip_interface::WipType;
#[derive(WipType)]
enum MixedEnum {
Empty,
Payload(String),
}
fn main() {}
@@ -0,0 +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
|
4 | enum MixedEnum {
| ^^^^
@@ -0,0 +1,8 @@
use wip_interface::{OperationResult, interface};
#[interface]
trait PrivateInterface {
fn load(&self) -> OperationResult<String>;
}
fn main() {}
@@ -0,0 +1,15 @@
error: a WIP interface trait must be public
--> tests/ui/private_trait.rs:3:1
|
3 | #[interface]
| ^^^^^^^^^^^^
|
= note: this error originates in the attribute macro `interface` (in Nightly builds, run with -Z macro-backtrace for more info)
warning: unused import: `OperationResult`
--> tests/ui/private_trait.rs:1:21
|
1 | use wip_interface::{OperationResult, interface};
| ^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
@@ -0,0 +1,6 @@
use wip_interface::WipType;
#[derive(WipType)]
struct TupleDto(String, String);
fn main() {}
@@ -0,0 +1,5 @@
error: `WipType` supports only structs with named fields
--> tests/ui/tuple_dto.rs:4:16
|
4 | struct TupleDto(String, String);
| ^^^^^^^^^^^^^^^^
@@ -0,0 +1,8 @@
use wip_interface::{OperationResult, interface};
#[interface]
pub trait UnsupportedType {
fn load(&self, pair: (String, String)) -> OperationResult<String>;
}
fn main() {}
@@ -0,0 +1,41 @@
error[E0277]: the trait bound `(std::string::String, std::string::String): WipType` is not satisfied
--> tests/ui/unsupported_type.rs:5:26
|
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
error[E0277]: the trait bound `(std::string::String, std::string::String): WipType` is not satisfied
--> tests/ui/unsupported_type.rs:5:26
|
3 | #[interface]
| ------------ required by a bound introduced by this call
4 | pub trait UnsupportedType {
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
note: required by a bound in `wip_interface::__private::RecordDecoder::required`
--> src/lib.rs
|
| pub fn required<T: WipType>(&mut self, name: &str) -> Result<T, CodecError> {
| ^^^^^^^ required by this bound in `RecordDecoder::required`